@nadohq/mobile-client 0.36.0 → 0.38.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 (189) hide show
  1. package/README.md +19 -6
  2. package/dist/{MobileClient-D5TNTnLx.cjs → MobileClient-wjTrqP9z.cjs} +75 -12
  3. package/dist/MobileClient-wjTrqP9z.cjs.map +1 -0
  4. package/dist/MobileClient.cjs +1 -1
  5. package/dist/MobileClient.d.cts +62 -16
  6. package/dist/MobileClient.d.cts.map +1 -1
  7. package/dist/MobileClient.d.ts +62 -16
  8. package/dist/MobileClient.d.ts.map +1 -1
  9. package/dist/MobileClient.js +75 -12
  10. package/dist/MobileClient.js.map +1 -1
  11. package/dist/{MobileServerFailureError-CPFjwUBx.d.cts → MobileServerFailureError-BvQYrxjx.d.cts} +3 -3
  12. package/dist/{MobileServerFailureError-CPFjwUBx.d.cts.map → MobileServerFailureError-BvQYrxjx.d.cts.map} +1 -1
  13. package/dist/{MobileServerFailureError-Terv2Wzy.d.ts → MobileServerFailureError-CrNpL_L4.d.ts} +3 -3
  14. package/dist/{MobileServerFailureError-Terv2Wzy.d.ts.map → MobileServerFailureError-CrNpL_L4.d.ts.map} +1 -1
  15. package/dist/{buildSignedMobileRequest-BScnCGPF.d.ts → buildSignedMobileRequest-BqLRFLKV.d.ts} +2 -2
  16. package/dist/{buildSignedMobileRequest-BScnCGPF.d.ts.map → buildSignedMobileRequest-BqLRFLKV.d.ts.map} +1 -1
  17. package/dist/{buildSignedMobileRequest-CXfnhc-4.d.cts → buildSignedMobileRequest-DkkwEqIu.d.cts} +2 -2
  18. package/dist/{buildSignedMobileRequest-CXfnhc-4.d.cts.map → buildSignedMobileRequest-DkkwEqIu.d.cts.map} +1 -1
  19. package/dist/{canonicalize-CQ-pMYIt.d.ts → canonicalize-BRhCpYm1.d.ts} +2 -2
  20. package/dist/canonicalize-BRhCpYm1.d.ts.map +1 -0
  21. package/dist/{canonicalize-hzhboB-C.d.cts → canonicalize-DtpaaA2B.d.cts} +2 -2
  22. package/dist/canonicalize-DtpaaA2B.d.cts.map +1 -0
  23. package/dist/{clientTypes-BwakovWk.d.cts → clientTypes-CB_itU4Z.d.ts} +147 -14
  24. package/dist/clientTypes-CB_itU4Z.d.ts.map +1 -0
  25. package/dist/{clientTypes-D-o1_2Yc.d.ts → clientTypes-CceWsQzi.d.cts} +147 -14
  26. package/dist/clientTypes-CceWsQzi.d.cts.map +1 -0
  27. package/dist/dataMappers.cjs +79 -6
  28. package/dist/dataMappers.cjs.map +1 -1
  29. package/dist/dataMappers.d.cts +27 -5
  30. package/dist/dataMappers.d.cts.map +1 -1
  31. package/dist/dataMappers.d.ts +27 -5
  32. package/dist/dataMappers.d.ts.map +1 -1
  33. package/dist/dataMappers.js +75 -7
  34. package/dist/dataMappers.js.map +1 -1
  35. package/dist/{eip712MethodByType-3aiJkrHj.d.cts → eip712MethodByType-1xUkvTIp.d.cts} +2 -2
  36. package/dist/eip712MethodByType-1xUkvTIp.d.cts.map +1 -0
  37. package/dist/{eip712MethodByType-zNvZukU7.d.ts → eip712MethodByType-CrqBJIrp.d.ts} +2 -2
  38. package/dist/eip712MethodByType-CrqBJIrp.d.ts.map +1 -0
  39. package/dist/follow-Bh3UmXMc.d.cts +23 -0
  40. package/dist/follow-Bh3UmXMc.d.cts.map +1 -0
  41. package/dist/follow-DjkQm1vR.d.ts +23 -0
  42. package/dist/follow-DjkQm1vR.d.ts.map +1 -0
  43. package/dist/index.cjs +6 -1
  44. package/dist/index.d.cts +17 -16
  45. package/dist/index.d.ts +17 -16
  46. package/dist/index.js +2 -1
  47. package/dist/{mobileErrorCodes-CuQ5lyBV.d.ts → mobileErrorCodes-B3eR6t4b.d.ts} +8 -2
  48. package/dist/mobileErrorCodes-B3eR6t4b.d.ts.map +1 -0
  49. package/dist/{mobileErrorCodes-D_SyBm0k.d.cts → mobileErrorCodes-DkzBW4v3.d.cts} +8 -2
  50. package/dist/mobileErrorCodes-DkzBW4v3.d.cts.map +1 -0
  51. package/dist/{patterns-B5gi7OfV.d.ts → patterns-C-aUAnKl.d.ts} +1 -1
  52. package/dist/patterns-C-aUAnKl.d.ts.map +1 -0
  53. package/dist/{patterns-Db6fgz8i.d.cts → patterns-DS8P5LH0.d.cts} +1 -1
  54. package/dist/patterns-DS8P5LH0.d.cts.map +1 -0
  55. package/dist/{payloadHash-DVkEt-Rg.d.cts → payloadHash-CIo8Hkh6.d.cts} +2 -2
  56. package/dist/payloadHash-CIo8Hkh6.d.cts.map +1 -0
  57. package/dist/{payloadHash-BxL26uwq.d.ts → payloadHash-Cv4WwZCZ.d.ts} +2 -2
  58. package/dist/payloadHash-Cv4WwZCZ.d.ts.map +1 -0
  59. package/dist/{serverBaseTypes-B_KHFDab.d.ts → serverBaseTypes-C2JGib--.d.ts} +2 -2
  60. package/dist/{serverBaseTypes-B_KHFDab.d.ts.map → serverBaseTypes-C2JGib--.d.ts.map} +1 -1
  61. package/dist/{serverBaseTypes-C8KjE9Fm.d.cts → serverBaseTypes-YkCmgmt1.d.cts} +2 -2
  62. package/dist/{serverBaseTypes-C8KjE9Fm.d.cts.map → serverBaseTypes-YkCmgmt1.d.cts.map} +1 -1
  63. package/dist/serverExecuteTypes-C2NoNEwu.d.ts +50 -0
  64. package/dist/serverExecuteTypes-C2NoNEwu.d.ts.map +1 -0
  65. package/dist/serverExecuteTypes-DPZbs9ym.d.cts +50 -0
  66. package/dist/serverExecuteTypes-DPZbs9ym.d.cts.map +1 -0
  67. package/dist/{serverModelTypes-_cX99seo.d.ts → serverModelTypes-CDcRlRtw.d.ts} +57 -10
  68. package/dist/serverModelTypes-CDcRlRtw.d.ts.map +1 -0
  69. package/dist/{serverModelTypes-CwgibK-l.d.cts → serverModelTypes-CUzqFA6x.d.cts} +57 -10
  70. package/dist/serverModelTypes-CUzqFA6x.d.cts.map +1 -0
  71. package/dist/{serverQueryTypes-DwE1iJ9f.d.cts → serverQueryTypes-B0xI1G39.d.ts} +21 -9
  72. package/dist/serverQueryTypes-B0xI1G39.d.ts.map +1 -0
  73. package/dist/{serverQueryTypes-NIrPm2Sl.d.ts → serverQueryTypes-RdcIqRnM.d.cts} +21 -9
  74. package/dist/serverQueryTypes-RdcIqRnM.d.cts.map +1 -0
  75. package/dist/serverRequestTypes-Ddws3yXc.d.cts +116 -0
  76. package/dist/serverRequestTypes-Ddws3yXc.d.cts.map +1 -0
  77. package/dist/serverRequestTypes-Dv9NR-hQ.d.ts +116 -0
  78. package/dist/serverRequestTypes-Dv9NR-hQ.d.ts.map +1 -0
  79. package/dist/{serverResponseGuards-Cep5s7j5.d.ts → serverResponseGuards-BjIzucYt.d.ts} +2 -2
  80. package/dist/{serverResponseGuards-Cep5s7j5.d.ts.map → serverResponseGuards-BjIzucYt.d.ts.map} +1 -1
  81. package/dist/{serverResponseGuards-Dxt9_aGh.d.cts → serverResponseGuards-Dg9WbqBA.d.cts} +2 -2
  82. package/dist/{serverResponseGuards-Dxt9_aGh.d.cts.map → serverResponseGuards-Dg9WbqBA.d.cts.map} +1 -1
  83. package/dist/signing/buildSignedMobileRequest.cjs +1 -1
  84. package/dist/signing/buildSignedMobileRequest.d.cts +1 -1
  85. package/dist/signing/buildSignedMobileRequest.d.ts +1 -1
  86. package/dist/signing/canonicalize.cjs +5 -0
  87. package/dist/signing/canonicalize.cjs.map +1 -1
  88. package/dist/signing/canonicalize.d.cts +1 -1
  89. package/dist/signing/canonicalize.d.ts +1 -1
  90. package/dist/signing/canonicalize.js +5 -0
  91. package/dist/signing/canonicalize.js.map +1 -1
  92. package/dist/signing/eip712MethodByType.cjs +2 -1
  93. package/dist/signing/eip712MethodByType.cjs.map +1 -1
  94. package/dist/signing/eip712MethodByType.d.cts +1 -1
  95. package/dist/signing/eip712MethodByType.d.ts +1 -1
  96. package/dist/signing/eip712MethodByType.js +2 -1
  97. package/dist/signing/eip712MethodByType.js.map +1 -1
  98. package/dist/signing/index.d.cts +6 -6
  99. package/dist/signing/index.d.ts +6 -6
  100. package/dist/signing/payloadHash.cjs +1 -1
  101. package/dist/signing/payloadHash.d.cts +1 -1
  102. package/dist/signing/payloadHash.d.ts +1 -1
  103. package/dist/signing/types.d.cts +2 -2
  104. package/dist/signing/types.d.ts +2 -2
  105. package/dist/types/MobileServerFailureError.cjs +1 -1
  106. package/dist/types/MobileServerFailureError.d.cts +1 -1
  107. package/dist/types/MobileServerFailureError.d.ts +1 -1
  108. package/dist/types/clientTypes.d.cts +2 -2
  109. package/dist/types/clientTypes.d.ts +2 -2
  110. package/dist/types/index.d.cts +9 -9
  111. package/dist/types/index.d.ts +9 -9
  112. package/dist/types/mobileErrorCodes.cjs +13 -1
  113. package/dist/types/mobileErrorCodes.cjs.map +1 -1
  114. package/dist/types/mobileErrorCodes.d.cts +1 -1
  115. package/dist/types/mobileErrorCodes.d.ts +1 -1
  116. package/dist/types/mobileErrorCodes.js +12 -0
  117. package/dist/types/mobileErrorCodes.js.map +1 -1
  118. package/dist/types/serverBaseTypes.d.cts +1 -1
  119. package/dist/types/serverBaseTypes.d.ts +1 -1
  120. package/dist/types/serverExecuteTypes.d.cts +2 -2
  121. package/dist/types/serverExecuteTypes.d.ts +2 -2
  122. package/dist/types/serverModelTypes.cjs.map +1 -1
  123. package/dist/types/serverModelTypes.d.cts +2 -2
  124. package/dist/types/serverModelTypes.d.ts +2 -2
  125. package/dist/types/serverModelTypes.js.map +1 -1
  126. package/dist/types/serverQueryTypes.d.cts +2 -2
  127. package/dist/types/serverQueryTypes.d.ts +2 -2
  128. package/dist/types/serverRequestTypes.d.cts +2 -2
  129. package/dist/types/serverRequestTypes.d.ts +2 -2
  130. package/dist/{types-47IPsDyA.d.cts → types-BIIvIzG2.d.ts} +20 -7
  131. package/dist/types-BIIvIzG2.d.ts.map +1 -0
  132. package/dist/{types-D5kFJzEX.d.ts → types-UmYaeq1C.d.cts} +20 -7
  133. package/dist/types-UmYaeq1C.d.cts.map +1 -0
  134. package/dist/utils/follow.cjs +28 -0
  135. package/dist/utils/follow.cjs.map +1 -0
  136. package/dist/utils/follow.d.cts +2 -0
  137. package/dist/utils/follow.d.ts +2 -0
  138. package/dist/utils/follow.js +24 -0
  139. package/dist/utils/follow.js.map +1 -0
  140. package/dist/utils/index.cjs +5 -0
  141. package/dist/utils/index.d.cts +4 -3
  142. package/dist/utils/index.d.ts +4 -3
  143. package/dist/utils/index.js +2 -1
  144. package/dist/utils/patterns.d.cts +1 -1
  145. package/dist/utils/patterns.d.ts +1 -1
  146. package/dist/utils/serverResponseGuards.d.cts +1 -1
  147. package/dist/utils/serverResponseGuards.d.ts +1 -1
  148. package/package.json +3 -3
  149. package/src/MobileClient.ts +117 -21
  150. package/src/dataMappers.ts +109 -5
  151. package/src/signing/canonicalize.ts +6 -0
  152. package/src/signing/eip712MethodByType.ts +1 -0
  153. package/src/signing/signing.test.ts +32 -4
  154. package/src/signing/types.ts +18 -1
  155. package/src/types/clientTypes.ts +152 -11
  156. package/src/types/mobileErrorCodes.ts +12 -0
  157. package/src/types/serverExecuteTypes.ts +40 -2
  158. package/src/types/serverModelTypes.ts +58 -8
  159. package/src/types/serverQueryTypes.ts +18 -4
  160. package/src/types/serverRequestTypes.ts +50 -4
  161. package/src/utils/follow.ts +22 -0
  162. package/src/utils/index.ts +1 -0
  163. package/dist/MobileClient-D5TNTnLx.cjs.map +0 -1
  164. package/dist/canonicalize-CQ-pMYIt.d.ts.map +0 -1
  165. package/dist/canonicalize-hzhboB-C.d.cts.map +0 -1
  166. package/dist/clientTypes-BwakovWk.d.cts.map +0 -1
  167. package/dist/clientTypes-D-o1_2Yc.d.ts.map +0 -1
  168. package/dist/eip712MethodByType-3aiJkrHj.d.cts.map +0 -1
  169. package/dist/eip712MethodByType-zNvZukU7.d.ts.map +0 -1
  170. package/dist/mobileErrorCodes-CuQ5lyBV.d.ts.map +0 -1
  171. package/dist/mobileErrorCodes-D_SyBm0k.d.cts.map +0 -1
  172. package/dist/patterns-B5gi7OfV.d.ts.map +0 -1
  173. package/dist/patterns-Db6fgz8i.d.cts.map +0 -1
  174. package/dist/payloadHash-BxL26uwq.d.ts.map +0 -1
  175. package/dist/payloadHash-DVkEt-Rg.d.cts.map +0 -1
  176. package/dist/serverExecuteTypes-Cb5v7y0A.d.cts +0 -19
  177. package/dist/serverExecuteTypes-Cb5v7y0A.d.cts.map +0 -1
  178. package/dist/serverExecuteTypes-GFjZj_DC.d.ts +0 -19
  179. package/dist/serverExecuteTypes-GFjZj_DC.d.ts.map +0 -1
  180. package/dist/serverModelTypes-CwgibK-l.d.cts.map +0 -1
  181. package/dist/serverModelTypes-_cX99seo.d.ts.map +0 -1
  182. package/dist/serverQueryTypes-DwE1iJ9f.d.cts.map +0 -1
  183. package/dist/serverQueryTypes-NIrPm2Sl.d.ts.map +0 -1
  184. package/dist/serverRequestTypes-516zXnQ6.d.ts +0 -72
  185. package/dist/serverRequestTypes-516zXnQ6.d.ts.map +0 -1
  186. package/dist/serverRequestTypes-GAtVYIda.d.cts +0 -72
  187. package/dist/serverRequestTypes-GAtVYIda.d.cts.map +0 -1
  188. package/dist/types-47IPsDyA.d.cts.map +0 -1
  189. package/dist/types-D5kFJzEX.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nadohq/mobile-client",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "HTTP client for the Nado mobile service API — username claims, profiles, privacy settings, and push notification devices & preferences",
@@ -42,7 +42,7 @@
42
42
  "types": "./dist/index.d.ts",
43
43
  "dependencies": {
44
44
  "@msgpack/msgpack": "3.1.3",
45
- "@nadohq/shared": "^0.36.0",
45
+ "@nadohq/shared": "^0.38.0",
46
46
  "axios": "1.16.1"
47
47
  },
48
48
  "peerDependencies": {
@@ -54,5 +54,5 @@
54
54
  "bignumber.js": "11.1.1",
55
55
  "viem": "2.52.0"
56
56
  },
57
- "gitHead": "315e4f23dadefeb2f86f713e423241e81467d4c3"
57
+ "gitHead": "1a536b7f05b1b042ef257b290cdb8c83f48f4dd0"
58
58
  }
@@ -7,28 +7,38 @@ import {
7
7
  import axios, { AxiosInstance, AxiosResponse } from 'axios';
8
8
  import {
9
9
  mapMobileFeedPage,
10
+ mapMobileFollowListPage,
11
+ mapMobileFollowListParamsToServer,
12
+ mapMobileFollowMutationResult,
10
13
  mapMobileNotificationPreferences,
11
14
  mapMobileNotificationPreferencesToServer,
15
+ mapMobileProfilesIncludeToServer,
12
16
  mapMobilePublicProfile,
13
17
  mapMobileRegisteredWallet,
14
18
  } from './dataMappers';
15
19
  import {
16
20
  buildSignedMobileRequest,
17
21
  MobileSignedInner,
22
+ MobileSignedInnerByType,
18
23
  MobileSignedInnerParams,
19
24
  MobileSignedRequest,
20
25
  } from './signing';
21
26
  import {
22
27
  GetMobileFeedParams,
28
+ GetMobileFollowersParams,
29
+ GetMobileFollowingParams,
23
30
  GetMobileNotificationPreferencesParams,
24
- GetMobilePublicProfileParams,
31
+ GetMobileProfilesParams,
25
32
  GetMobileRegisteredWalletParams,
26
33
  GetMobileUsernameAvailabilityParams,
27
34
  MobileFeedPage,
35
+ MobileFollowListPage,
36
+ MobileFollowMutationResult,
28
37
  MobileNotificationPreferences,
29
38
  MobilePublicProfile,
30
39
  MobileRegisteredWallet,
31
40
  MobileRegisterExpoTokenParams,
41
+ MobileSetFollowParams,
32
42
  MobileSetPrivateModeParams,
33
43
  MobileSetUsernameParams,
34
44
  MobileSignedRequestParams,
@@ -40,6 +50,7 @@ import { MobileServerFailureError } from './types/MobileServerFailureError';
40
50
  import { MobileServerSuccessResponse } from './types/serverBaseTypes';
41
51
  import {
42
52
  MobileServerExecuteResult,
53
+ MobileServerExecuteSuccessResponse,
43
54
  MobileServerPublicExecuteRequest,
44
55
  } from './types/serverExecuteTypes';
45
56
  import {
@@ -47,6 +58,7 @@ import {
47
58
  MobileServerPublicQuerySuccessResponse,
48
59
  } from './types/serverQueryTypes';
49
60
  import {
61
+ MobileServerExecuteRequestType,
50
62
  MobileServerPublicQueryRequestByType,
51
63
  MobileServerPublicQueryRequestType,
52
64
  } from './types/serverRequestTypes';
@@ -78,8 +90,8 @@ export interface MobileClientOpts {
78
90
  }
79
91
 
80
92
  /**
81
- * Client for the Nado mobile service API: usernames, public profile lookups, the global trade feed, privacy
82
- * settings, and push notification device/preference management.
93
+ * Client for the Nado mobile service API: usernames, public profile lookups, the global trade feed, the
94
+ * follower/following graph, privacy settings, and push notification device/preference management.
83
95
  */
84
96
  export class MobileClient {
85
97
  readonly opts: MobileClientOpts;
@@ -125,22 +137,73 @@ export class MobileClient {
125
137
  }
126
138
 
127
139
  /**
128
- * Looks up a subaccount's public profile. Every non-isolated subaccount resolves, with `username` and
129
- * `displayName` `null` until a username is claimed. Private Mode does not hide the profile itself, only the
130
- * account's activity.
140
+ * Looks up public profiles, optionally with follower totals and a follow summary. Returns one profile per
141
+ * requested subaccount, in the requested order, so callers can correlate positionally. Every non-isolated
142
+ * subaccount resolves, with `username` and `displayName` `null` until a username is claimed. Private Mode
143
+ * does not hide the profile itself, only the account's activity.
131
144
  *
132
- * @throws {MobileServerFailureError} With error code `PROFILE_NOT_FOUND` if the subaccount is in the
133
- * engine-created isolated namespace, which cannot own a profile.
145
+ * This is the only profile route, so use it for a single subaccount too. It is unsigned, which means
146
+ * `include.followSummary.viewAs` is an unauthenticated claim.
147
+ *
148
+ * @throws {MobileServerFailureError} With error code `INVALID_PROFILES_REQUEST` if `subaccounts` is empty,
149
+ * holds duplicates, or exceeds `MOBILE_PROFILES_MAX_BATCH_SIZE` (25), or `PROFILE_NOT_FOUND` if any
150
+ * requested subaccount — or the follow summary's `viewAs` — is in the engine-created isolated namespace.
151
+ * One isolated member fails the whole batch rather than being omitted.
152
+ */
153
+ async getProfiles(
154
+ params: GetMobileProfilesParams,
155
+ ): Promise<MobilePublicProfile[]> {
156
+ const body: MobileServerPublicQueryRequest<'profiles'> = {
157
+ type: 'profiles',
158
+ subaccounts: params.subaccounts.map(subaccountToHex),
159
+ include: params.include
160
+ ? mapMobileProfilesIncludeToServer(params.include)
161
+ : undefined,
162
+ };
163
+ const data = await this.publicQuery(body);
164
+ return data.profiles.map(mapMobilePublicProfile);
165
+ }
166
+
167
+ /**
168
+ * Fetches a page of the accounts that follow the given Profile.
169
+ *
170
+ * Ordering depends on `viewAs`. With it, accounts the Viewer already follows come first, unfamiliar
171
+ * accounts second, and every row carries an `isFollowing`. Without it, the page is ordered by the listed
172
+ * relationship alone and no row carries an `isFollowing`. In both cases the newest listed relationship
173
+ * comes first within a group, with a bytes32 tie-break. The grouping is evaluated per request against the
174
+ * live graph, so following an account from inside the list moves it between groups on the next page —
175
+ * deduplicate by subaccount and do not re-sort locally.
176
+ *
177
+ * @throws {MobileServerFailureError} With error code `PROFILE_NOT_FOUND` if the viewed Profile or `viewAs`
178
+ * is isolated, `INVALID_FOLLOW_CURSOR` if the cursor is malformed or was issued for a different `viewAs`,
179
+ * Profile, or list direction (discard it and restart from the first page), or `INVALID_FOLLOW_LIMIT` if
180
+ * `limit` is outside 1–50.
181
+ */
182
+ async getFollowers(
183
+ params: GetMobileFollowersParams,
184
+ ): Promise<MobileFollowListPage> {
185
+ const body: MobileServerPublicQueryRequest<'followers'> = {
186
+ type: 'followers',
187
+ ...mapMobileFollowListParamsToServer(params),
188
+ };
189
+ const data = await this.publicQuery(body);
190
+ return mapMobileFollowListPage(data);
191
+ }
192
+
193
+ /**
194
+ * Fetches a page of the accounts the given Profile follows. Ordering, cursor rules, and errors match
195
+ * {@link MobileClient.getFollowers}. When `viewAs` reads its own Following list every row is familiar, so
196
+ * every `isFollowing` is `true`.
134
197
  */
135
- async getPublicProfile(
136
- params: GetMobilePublicProfileParams,
137
- ): Promise<MobilePublicProfile> {
138
- const body: MobileServerPublicQueryRequest<'profile'> = {
139
- type: 'profile',
140
- subaccount: subaccountToHex(params),
198
+ async getFollowing(
199
+ params: GetMobileFollowingParams,
200
+ ): Promise<MobileFollowListPage> {
201
+ const body: MobileServerPublicQueryRequest<'following'> = {
202
+ type: 'following',
203
+ ...mapMobileFollowListParamsToServer(params),
141
204
  };
142
205
  const data = await this.publicQuery(body);
143
- return mapMobilePublicProfile(data.profile);
206
+ return mapMobileFollowListPage(data);
144
207
  }
145
208
 
146
209
  /**
@@ -314,15 +377,43 @@ export class MobileClient {
314
377
  return this.execute(signedRequest);
315
378
  }
316
379
 
380
+ /**
381
+ * Sets whether the signing subaccount follows a Profile. Both directions are idempotent: following again
382
+ * keeps the original relationship creation time, so it does not move the account in recency order, and
383
+ * unfollowing a relationship that does not exist succeeds with `isFollowing: false`. Unfollowing deletes
384
+ * the relationship rather than deactivating it, so a later follow gets a new creation time and moves the
385
+ * account to the front of recency order.
386
+ *
387
+ * Only `isFollowing: true` is checked against the canonical Query DB subaccount index, so unfollowing still
388
+ * works while that index lags.
389
+ *
390
+ * @throws {MobileServerFailureError} With error code `INVALID_FOLLOW_TARGET` if the target is the sender or
391
+ * either party is isolated, `FOLLOWER_NOT_ELIGIBLE` if the sender is not yet in the canonical Query DB
392
+ * subaccount index, or `FOLLOWING_NOT_FOUND` if the target is not. Both index errors are transient for an
393
+ * account that is being recorded, so they are retryable after catch-up — unlike `INVALID_FOLLOW_TARGET`.
394
+ */
395
+ async setFollow(
396
+ params: MobileSetFollowParams,
397
+ ): Promise<MobileFollowMutationResult> {
398
+ const signedRequest = await this.getSignedRequest('set_follow', params, {
399
+ subaccount: subaccountToHex(params.target),
400
+ is_following: params.isFollowing,
401
+ });
402
+ const data = await this.execute(signedRequest);
403
+ return mapMobileFollowMutationResult(data);
404
+ }
405
+
317
406
  /*
318
407
  Base Fns
319
408
  */
320
409
 
410
+ // Returns the request narrowed to `type` so `execute` can infer its response from the request it is given,
411
+ // instead of being told the type a second time at the call site.
321
412
  protected async getSignedRequest<T extends MobileSignedInner['type']>(
322
413
  type: T,
323
414
  params: MobileSignedRequestParams,
324
415
  innerParams: MobileSignedInnerParams<T>,
325
- ): Promise<MobileSignedRequest> {
416
+ ): Promise<MobileSignedRequest<MobileSignedInnerByType<T>>> {
326
417
  // Use the linked signer if provided, otherwise use the default signer provided to the client
327
418
  const walletClient =
328
419
  this.opts.linkedSignerWalletClient ?? this.opts.walletClient;
@@ -331,7 +422,10 @@ export class MobileClient {
331
422
  throw new WalletNotProvidedError();
332
423
  }
333
424
 
334
- const inner = { type, ...innerParams } as unknown as MobileSignedInner;
425
+ const inner = {
426
+ type,
427
+ ...innerParams,
428
+ } as unknown as MobileSignedInnerByType<T>;
335
429
  return buildSignedMobileRequest({ ...params, walletClient, inner });
336
430
  }
337
431
 
@@ -367,9 +461,11 @@ export class MobileClient {
367
461
  return response.data as MobileServerSuccessResponse;
368
462
  }
369
463
 
370
- protected async execute(
371
- body: MobileSignedRequest,
372
- ): Promise<MobileServerSuccessResponse> {
464
+ // `T` is inferred from the body's `type` rather than declared by the caller, for the same reason
465
+ // `publicQuery` spells its body out as an intersection: an indexed access is not an inference site.
466
+ protected async execute<T extends MobileServerExecuteRequestType>(
467
+ body: MobileSignedRequest & { type: T },
468
+ ): Promise<MobileServerExecuteSuccessResponse<T>> {
373
469
  const response = await this.axiosInstance.post<MobileServerExecuteResult>(
374
470
  `${this.opts.url}/mobile/execute`,
375
471
  body,
@@ -379,7 +475,7 @@ export class MobileClient {
379
475
  this.checkServerStatus(response);
380
476
 
381
477
  // checkServerStatus catches the failure result and throws the error, so the cast to the success response is acceptable here
382
- return response.data as MobileServerSuccessResponse;
478
+ return response.data as MobileServerExecuteSuccessResponse<T>;
383
479
  }
384
480
 
385
481
  /**
@@ -1,24 +1,41 @@
1
+ import { subaccountToHex } from '@nadohq/shared';
1
2
  import {
3
+ GetMobileFollowListParams,
2
4
  MobileFeedPage,
3
5
  MobileFeedTrade,
6
+ MobileFollowListPage,
7
+ MobileFollowMutationResult,
8
+ MobileFollowSummary,
9
+ MobileIdentitySummary,
4
10
  MobileNotificationPreferenceScope,
5
11
  MobileNotificationPreferences,
12
+ MobileProfilesInclude,
6
13
  MobilePublicProfile,
7
14
  MobileRegisteredWallet,
8
15
  } from './types/clientTypes';
16
+ import { MobileServerFollowMutationResponse } from './types/serverExecuteTypes';
9
17
  import {
10
18
  MobileServerFeedTrade,
19
+ MobileServerFollowSummary,
20
+ MobileServerIdentitySummary,
11
21
  MobileServerNotificationPreferenceScope,
12
22
  MobileServerNotificationPreferences,
13
23
  MobileServerProfile,
14
24
  } from './types/serverModelTypes';
15
25
  import {
16
26
  MobileServerFeedResponse,
27
+ MobileServerFollowListResponse,
17
28
  MobileServerRegisteredWalletResponse,
18
29
  } from './types/serverQueryTypes';
30
+ import {
31
+ MobileServerFollowListRequest,
32
+ MobileServerProfilesInclude,
33
+ } from './types/serverRequestTypes';
19
34
 
20
35
  /**
21
- * Maps a server-side public profile (snake_case) to its client-side (camelCase) representation.
36
+ * Maps a server-side public profile (snake_case) to its client-side (camelCase) representation. The three
37
+ * include-gated fields stay `undefined` when the backend omitted them, rather than being coerced to `null`
38
+ * or zero, so callers can tell "not requested" from a real value.
22
39
  */
23
40
  export function mapMobilePublicProfile(
24
41
  server: MobileServerProfile,
@@ -28,6 +45,67 @@ export function mapMobilePublicProfile(
28
45
  username: server.username,
29
46
  displayName: server.display_name,
30
47
  privateMode: server.private_mode,
48
+ followerCount: server.follower_count,
49
+ followingCount: server.following_count,
50
+ followSummary: server.follow_summary
51
+ ? mapMobileFollowSummary(server.follow_summary)
52
+ : undefined,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Maps a server-side identity summary (snake_case) to its client-side (camelCase) representation.
58
+ */
59
+ function mapMobileIdentitySummary(
60
+ server: MobileServerIdentitySummary,
61
+ ): MobileIdentitySummary {
62
+ return {
63
+ subaccount: server.subaccount,
64
+ username: server.username,
65
+ displayName: server.display_name,
66
+ avatarUrl: server.avatar_url,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Maps a server-side follow summary to its client-side representation.
72
+ */
73
+ function mapMobileFollowSummary(
74
+ server: MobileServerFollowSummary,
75
+ ): MobileFollowSummary {
76
+ return {
77
+ isFollowing: server.is_following,
78
+ followedByCount: server.followed_by_count,
79
+ followedBy: server.followed_by.map(mapMobileIdentitySummary),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Maps a server-side Followers or Following response to a client-side {@link MobileFollowListPage}. Rows
85
+ * keep `isFollowing` absent when the request named no `viewAs`, rather than defaulting it to `false`.
86
+ */
87
+ export function mapMobileFollowListPage(
88
+ server: MobileServerFollowListResponse,
89
+ ): MobileFollowListPage {
90
+ return {
91
+ accounts: server.accounts.map((account) => ({
92
+ identity: mapMobileIdentitySummary(account.identity),
93
+ isFollowing: account.is_following,
94
+ followerCount: account.follower_count,
95
+ })),
96
+ nextCursor: server.next_cursor,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Maps a server-side follow or unfollow response to its client-side representation.
102
+ */
103
+ export function mapMobileFollowMutationResult(
104
+ server: MobileServerFollowMutationResponse,
105
+ ): MobileFollowMutationResult {
106
+ return {
107
+ isFollowing: server.is_following,
108
+ followerCount: server.follower_count,
31
109
  };
32
110
  }
33
111
 
@@ -56,6 +134,35 @@ function mapMobileNotificationPreferenceScope(
56
134
  return { type: 'product', productId: server.product_id };
57
135
  }
58
136
 
137
+ /**
138
+ * Maps client-side profiles includes (camelCase) to the server-side (snake_case) wire shape.
139
+ */
140
+ export function mapMobileProfilesIncludeToServer(
141
+ include: MobileProfilesInclude,
142
+ ): MobileServerProfilesInclude {
143
+ return {
144
+ follow_counts: include.followCounts,
145
+ follow_summary: include.followSummary
146
+ ? { view_as: subaccountToHex(include.followSummary.viewAs) }
147
+ : undefined,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Maps the client-side params of a Followers or Following page to the server-side wire shape. Both list
153
+ * directions take identical params and differ only in the `type` the caller dispatches on.
154
+ */
155
+ export function mapMobileFollowListParamsToServer(
156
+ params: GetMobileFollowListParams,
157
+ ): MobileServerFollowListRequest {
158
+ return {
159
+ subaccount: subaccountToHex(params.target),
160
+ view_as: params.viewAs ? subaccountToHex(params.viewAs) : undefined,
161
+ cursor: params.cursor,
162
+ limit: params.limit,
163
+ };
164
+ }
165
+
59
166
  /**
60
167
  * Maps client-side notification preferences (camelCase) to the server-side (snake_case) wire shape.
61
168
  */
@@ -86,11 +193,8 @@ function mapMobileNotificationPreferenceScopeToServer(
86
193
  */
87
194
  function mapMobileFeedTrade(server: MobileServerFeedTrade): MobileFeedTrade {
88
195
  return {
196
+ ...mapMobileIdentitySummary(server),
89
197
  orderDigest: server.order_digest,
90
- subaccount: server.subaccount,
91
- username: server.username,
92
- displayName: server.display_name,
93
- avatarUrl: server.avatar_url,
94
198
  productId: server.product_id,
95
199
  quantity: server.quantity,
96
200
  notional: server.notional,
@@ -23,6 +23,12 @@ export function canonicalizeMobileInner(
23
23
  locale: inner.locale,
24
24
  app_version: inner.app_version,
25
25
  };
26
+ case 'set_follow':
27
+ return {
28
+ type: 'set_follow',
29
+ subaccount: inner.subaccount,
30
+ is_following: inner.is_following,
31
+ };
26
32
  default: {
27
33
  throw new Error(
28
34
  `canonicalizeMobileInner: unhandled inner payload type: ${JSON.stringify(inner)}`,
@@ -10,4 +10,5 @@ export const MOBILE_EIP712_METHOD_BY_TYPE: Record<
10
10
  set_username: 'mobile:execute_set_username',
11
11
  set_private_mode: 'mobile:execute_set_private_mode',
12
12
  register_expo_token: 'mobile:execute_register_expo_token',
13
+ set_follow: 'mobile:execute_set_follow',
13
14
  };
@@ -19,8 +19,8 @@ const FIXED_PRIVATE_KEY =
19
19
  '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b786907' as const;
20
20
  const FIXED_VERIFYING_ADDR = '0x0000000000000000000000000000000000000001';
21
21
 
22
- // `set_username`, `set_private_mode`, and `register_expo_token` mirror the backend's pinned fixtures in
23
- // mobile/src/api/types.rs, so a divergence here is a real wire-format break rather than a stale expectation.
22
+ // Every hash here mirrors the backend's pinned fixtures in mobile/src/api/types.rs, so a divergence is a
23
+ // real wire-format break rather than a stale expectation.
24
24
  const PINNED_HASHES = {
25
25
  set_username:
26
26
  '0xb4a55d9eb0be4e5c9457761c0c34c75cf39f7c07bd170700d8dd5e9c7de4a659',
@@ -28,8 +28,16 @@ const PINNED_HASHES = {
28
28
  '0x4d12a06234d751e6ddcc01d8f70836bb5b7e207e573641d1efb5aaf2b0f30d10',
29
29
  register_expo_token:
30
30
  '0x1b9471afc9bde66f9bffd576d3326420e1bade4e16afa3a73ddc65f27155611c',
31
+ set_follow_true:
32
+ '0x1ff8529b7c1a0111313eed98536e591555a3bcab51133776ce30c6ef32b559b3',
33
+ set_follow_false:
34
+ '0xec9e9e8f32ac4fcf3015f8b7f20c338623ff85db0488703c7fb688519a46e2e5',
31
35
  } as const;
32
36
 
37
+ // Every follow fixture on the backend targets this subaccount.
38
+ const FIXTURE_TARGET_SUBACCOUNT =
39
+ '0x1111111111111111111111111111111111111111111111111111111111111111' as const;
40
+
33
41
  describe('[mobile-client]: signing (offline)', () => {
34
42
  describe('pinned payload hashes', () => {
35
43
  it('set_username', () => {
@@ -61,6 +69,26 @@ describe('[mobile-client]: signing (offline)', () => {
61
69
  const hash = getMobilePayloadHash(canonicalizeMobileInner(inner));
62
70
  expect(hash).toBe(PINNED_HASHES.register_expo_token);
63
71
  });
72
+
73
+ it('set_follow (follow)', () => {
74
+ const inner: MobileSignedInner = {
75
+ type: 'set_follow',
76
+ subaccount: FIXTURE_TARGET_SUBACCOUNT,
77
+ is_following: true,
78
+ };
79
+ const hash = getMobilePayloadHash(canonicalizeMobileInner(inner));
80
+ expect(hash).toBe(PINNED_HASHES.set_follow_true);
81
+ });
82
+
83
+ it('set_follow (unfollow)', () => {
84
+ const inner: MobileSignedInner = {
85
+ type: 'set_follow',
86
+ subaccount: FIXTURE_TARGET_SUBACCOUNT,
87
+ is_following: false,
88
+ };
89
+ const hash = getMobilePayloadHash(canonicalizeMobileInner(inner));
90
+ expect(hash).toBe(PINNED_HASHES.set_follow_false);
91
+ });
64
92
  });
65
93
 
66
94
  it('canonicalizes msgpack key order regardless of input key order', () => {
@@ -76,8 +104,8 @@ describe('[mobile-client]: signing (offline)', () => {
76
104
  });
77
105
 
78
106
  it('canonicalizes multi-field key order regardless of input key order', () => {
79
- // register_expo_token is the only signed payload with more than one field, so it is where a caller can
80
- // actually get the order wrong; canonicalizeMobileInner must rebuild it in the backend's declaration order.
107
+ // register_expo_token has the most fields of any signed payload, so it is where a caller is most likely
108
+ // to get the order wrong; canonicalizeMobileInner must rebuild it in the backend's declaration order.
81
109
  const outOfOrderInner = {
82
110
  app_version: '1.2.3',
83
111
  locale: 'en-GB',
@@ -11,6 +11,11 @@ import { MobileNotificationPlatform } from '../types/serverModelTypes';
11
11
  * declares its structs — `type` first, then the remaining fields — or the hash won't match and signature
12
12
  * verification fails. This type only describes the shape; the authoritative runtime key order is enforced by
13
13
  * {@link canonicalizeMobileInner}, which is the source of truth. Keep the field order here in sync with it.
14
+ *
15
+ * Every bytes32 field must be lowercase hex. The backend decodes them case-insensitively but hashes its own
16
+ * re-serialization, which is always lowercase, so a mixed-case value is accepted on the wire and then fails
17
+ * authentication against a hash the client never reproduced. Build them with `subaccountToHex`, which
18
+ * lowercases; unlike bytes32, string fields such as `display_name` are hashed as-is and keep their casing.
14
19
  */
15
20
  export type MobileSignedInner =
16
21
  | { type: 'set_username'; display_name: string }
@@ -23,14 +28,26 @@ export type MobileSignedInner =
23
28
  // backend, which serializes `Option::None` as nil under the same key.
24
29
  locale: string | null;
25
30
  app_version: string | null;
31
+ }
32
+ | {
33
+ type: 'set_follow';
34
+ subaccount: Hex;
35
+ /** `true` follows the target, `false` unfollows it. Both directions are idempotent. */
36
+ is_following: boolean;
26
37
  };
27
38
 
39
+ /**
40
+ * The single {@link MobileSignedInner} member carrying the given `type` tag.
41
+ */
42
+ export type MobileSignedInnerByType<T extends MobileSignedInner['type']> =
43
+ Extract<MobileSignedInner, { type: T }>;
44
+
28
45
  /**
29
46
  * Type-specific payload fields for a signed inner request of a given `type` (the inner payload without its
30
47
  * `type` tag). Derived from {@link MobileSignedInner} so the two never drift apart.
31
48
  */
32
49
  export type MobileSignedInnerParams<T extends MobileSignedInner['type']> = Omit<
33
- Extract<MobileSignedInner, { type: T }>,
50
+ MobileSignedInnerByType<T>,
34
51
  'type'
35
52
  >;
36
53