@metamask/social-controllers 3.6.0 → 3.8.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.
@@ -1,7 +1,7 @@
1
1
  var _a;
2
2
  import { BaseDataService } from '@metamask/base-data-service';
3
3
  import { HttpError } from '@metamask/controller-utils';
4
- import { array, assign, boolean, enums, is, nullable, number, optional, record, string, type as structType, } from '@metamask/superstruct';
4
+ import { array, assign, boolean, enums, is, literal, nullable, number, optional, record, string, type as structType, union, } from '@metamask/superstruct';
5
5
  import { serviceName, SocialServiceErrorMessage } from './social-constants.js';
6
6
  import { TRADER_RANKING_TAGS, TradeStruct } from './social-types.js';
7
7
  // ---------------------------------------------------------------------------
@@ -33,16 +33,20 @@ const PositionStruct = structType({
33
33
  chain: string(),
34
34
  positionAmount: number(),
35
35
  isOpen: optional(boolean()),
36
- boughtUsd: number(),
37
- soldUsd: number(),
38
- realizedPnl: number(),
39
- costBasis: number(),
36
+ // Incomplete Clicker metrics serialize these as `null` (`undefined + n`
37
+ // becomes `NaN`, then JSON `null`). Rejecting the field used to fail the
38
+ // entire feed page, so they are nullable here.
39
+ boughtUsd: nullable(number()),
40
+ soldUsd: nullable(number()),
41
+ realizedPnl: nullable(number()),
42
+ costBasis: nullable(number()),
40
43
  trades: array(TradeStruct),
41
44
  lastTradeAt: number(),
42
45
  tokenImageUrl: optional(nullable(string())),
43
46
  currentValueUSD: optional(nullable(number())),
44
47
  pnlValueUsd: optional(nullable(number())),
45
48
  pnlPercent: optional(nullable(number())),
49
+ copyCount: optional(number()),
46
50
  perpPositionType: optional(nullable(enums(['long', 'short']))),
47
51
  perpLeverage: optional(nullable(number())),
48
52
  positionAmountWithLeverage: optional(nullable(number())),
@@ -164,12 +168,35 @@ const SwapCommentResponseStruct = structType({
164
168
  isAppUserComment: boolean(),
165
169
  metrics: CommentEngagementStruct,
166
170
  });
171
+ const CopyTradeAccountStruct = structType({
172
+ profileId: string(),
173
+ caip10: string(),
174
+ });
175
+ const CopyTradeIntentResponseStruct = structType({
176
+ id: string(),
177
+ origin: structType({
178
+ kind: literal('comment'),
179
+ id: string(),
180
+ }),
181
+ copierAccount: CopyTradeAccountStruct,
182
+ sourceAccount: nullable(CopyTradeAccountStruct),
183
+ execution: union([
184
+ structType({ product: literal('swaps'), quoteId: string() }),
185
+ structType({ product: literal('perps'), oid: string() }),
186
+ ]),
187
+ payoutEligibility: structType({
188
+ status: enums(['payable', 'non_payable']),
189
+ reason: string(),
190
+ }),
191
+ createdAt: string(),
192
+ });
167
193
  const FeedItemStruct = assign(PositionStruct, structType({
168
194
  actor: FeedActorStruct,
169
195
  timestamp: number(),
170
196
  authorComment: optional(nullable(AuthorCommentStruct)),
171
197
  commentCount: optional(number()),
172
198
  replyCount: optional(number()),
199
+ copyCount: optional(number()),
173
200
  firstTradeAt: optional(nullable(number())),
174
201
  holdTimeMs: optional(nullable(number())),
175
202
  entryPriceUsd: optional(nullable(number())),
@@ -182,6 +209,34 @@ const FeedResponseStruct = structType({
182
209
  items: array(FeedItemStruct),
183
210
  pagination: FeedPaginationStruct,
184
211
  });
212
+ /**
213
+ * One malformed feed item must not blank the page. When the envelope is
214
+ * well-shaped, keep items that match {@link FeedItemStruct} and drop the rest.
215
+ * Still throw when `items` or `pagination` themselves are unusable.
216
+ *
217
+ * @param feedData - Parsed JSON body from a feed endpoint.
218
+ * @param invalidMessage - Error message to throw when the envelope is unusable.
219
+ * @returns The validated feed response, possibly with invalid items dropped.
220
+ */
221
+ const parseFeedResponse = (feedData, invalidMessage) => {
222
+ if (is(feedData, FeedResponseStruct)) {
223
+ return feedData;
224
+ }
225
+ if (typeof feedData !== 'object' ||
226
+ feedData === null ||
227
+ !('items' in feedData) ||
228
+ !('pagination' in feedData)) {
229
+ throw new Error(invalidMessage);
230
+ }
231
+ const { items, pagination } = feedData;
232
+ if (!Array.isArray(items) || !is(pagination, FeedPaginationStruct)) {
233
+ throw new Error(invalidMessage);
234
+ }
235
+ return {
236
+ items: items.filter((item) => is(item, FeedItemStruct)),
237
+ pagination,
238
+ };
239
+ };
185
240
  const FollowersResponseStruct = structType({
186
241
  followers: array(ProfileSummaryStruct),
187
242
  count: number(),
@@ -227,7 +282,10 @@ const MESSENGER_EXPOSED_METHODS = [
227
282
  'fetchOpenPositions',
228
283
  'fetchClosedPositions',
229
284
  'fetchFollowers',
285
+ 'fetchTraderFollowing',
230
286
  'fetchFollowing',
287
+ 'fetchMyFollowing',
288
+ 'fetchMyFollowers',
231
289
  'fetchPositionById',
232
290
  'fetchFeed',
233
291
  'fetchTraderFeed',
@@ -235,6 +293,7 @@ const MESSENGER_EXPOSED_METHODS = [
235
293
  'reactToComment',
236
294
  'removeCommentReaction',
237
295
  'createSwapComment',
296
+ 'createCopyTradeIntent',
238
297
  'follow',
239
298
  'unfollow',
240
299
  'optOutOfLeaderboard',
@@ -314,7 +373,7 @@ export class SocialService extends BaseDataService {
314
373
  headers: authHeaders,
315
374
  });
316
375
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_LEADERBOARD_FAILED);
317
- const leaderboardData = await response.json();
376
+ const leaderboardData = (await response.json());
318
377
  if (!is(leaderboardData, LeaderboardResponseStruct)) {
319
378
  throw new Error(SocialServiceErrorMessage.FETCH_LEADERBOARD_INVALID_RESPONSE);
320
379
  }
@@ -341,7 +400,7 @@ export class SocialService extends BaseDataService {
341
400
  const authHeaders = await this.#getAuthHeaders();
342
401
  const response = await fetch(url, { headers: authHeaders });
343
402
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_TRADER_PROFILE_FAILED);
344
- const traderProfileData = await response.json();
403
+ const traderProfileData = (await response.json());
345
404
  if (!is(traderProfileData, TraderProfileResponseStruct)) {
346
405
  throw new Error(SocialServiceErrorMessage.FETCH_TRADER_PROFILE_INVALID_RESPONSE);
347
406
  }
@@ -385,22 +444,32 @@ export class SocialService extends BaseDataService {
385
444
  /**
386
445
  * Fetches a trader's MetaMask followers.
387
446
  *
388
- * Calls `GET ${baseUrl}/traders/${addressOrId}/followers`.
447
+ * Calls `GET ${baseUrl}/traders/${addressOrId}/followers`. Pass
448
+ * `mutuals: true` to append `?mutuals=true` (profiles that follow both this
449
+ * trader and the authenticated user; `count` is the mutual total). Requires a
450
+ * bearer token; the trader is identified by `addressOrId`, not the JWT
451
+ * subject (see {@link fetchMyFollowers} for the current user's inbound list).
389
452
  *
390
453
  * @param options - Options bag.
391
454
  * @param options.addressOrId - Wallet address or Clicker profile ID.
455
+ * @param options.mutuals - When true, request mutual followers only.
392
456
  * @returns The followers response.
393
457
  */
394
458
  async fetchFollowers(options) {
395
- const { addressOrId } = options;
459
+ const { addressOrId, mutuals } = options;
460
+ const mutualsOnly = mutuals === true;
396
461
  const followersResponse = await this.fetchQuery({
397
- queryKey: [`${this.name}:fetchFollowers`, addressOrId],
462
+ queryKey: [`${this.name}:fetchFollowers`, addressOrId, mutualsOnly],
463
+ staleTime: 0,
398
464
  queryFn: async () => {
399
- const url = `${this.#v1Url}/traders/${encodeURIComponent(addressOrId)}/followers`;
465
+ const url = new URL(`${this.#v1Url}/traders/${encodeURIComponent(addressOrId)}/followers`);
466
+ if (mutualsOnly) {
467
+ url.searchParams.append('mutuals', 'true');
468
+ }
400
469
  const authHeaders = await this.#getAuthHeaders();
401
- const response = await fetch(url, { headers: authHeaders });
470
+ const response = await fetch(url.toString(), { headers: authHeaders });
402
471
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_FOLLOWERS_FAILED);
403
- const followersData = await response.json();
472
+ const followersData = (await response.json());
404
473
  if (!is(followersData, FollowersResponseStruct)) {
405
474
  throw new Error(SocialServiceErrorMessage.FETCH_FOLLOWERS_INVALID_RESPONSE);
406
475
  }
@@ -409,6 +478,36 @@ export class SocialService extends BaseDataService {
409
478
  });
410
479
  return followersResponse;
411
480
  }
481
+ /**
482
+ * Fetches the list of traders a profile follows.
483
+ *
484
+ * Calls `GET ${baseUrl}/traders/${addressOrId}/following`. Requires a bearer
485
+ * token; the trader is identified by `addressOrId`, not the JWT subject (see
486
+ * {@link fetchFollowing} for the current user's outbound list).
487
+ *
488
+ * @param options - Options bag.
489
+ * @param options.addressOrId - Wallet address or Clicker profile ID.
490
+ * @returns The following response.
491
+ */
492
+ async fetchTraderFollowing(options) {
493
+ const { addressOrId } = options;
494
+ const followingResponse = await this.fetchQuery({
495
+ queryKey: [`${this.name}:fetchTraderFollowing`, addressOrId],
496
+ staleTime: 0,
497
+ queryFn: async () => {
498
+ const url = `${this.#v1Url}/traders/${encodeURIComponent(addressOrId)}/following`;
499
+ const authHeaders = await this.#getAuthHeaders();
500
+ const response = await fetch(url, { headers: authHeaders });
501
+ _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_TRADER_FOLLOWING_FAILED);
502
+ const followingData = (await response.json());
503
+ if (!is(followingData, FollowingResponseStruct)) {
504
+ throw new Error(SocialServiceErrorMessage.FETCH_TRADER_FOLLOWING_INVALID_RESPONSE);
505
+ }
506
+ return followingData;
507
+ },
508
+ });
509
+ return followingResponse;
510
+ }
412
511
  /**
413
512
  * Fetches a single position by its unique ID.
414
513
  *
@@ -427,7 +526,7 @@ export class SocialService extends BaseDataService {
427
526
  const authHeaders = await this.#getAuthHeaders();
428
527
  const response = await fetch(url, { headers: authHeaders });
429
528
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_POSITION_BY_ID_FAILED);
430
- const positionData = await response.json();
529
+ const positionData = (await response.json());
431
530
  if (!is(positionData, PositionStruct)) {
432
531
  throw new Error(SocialServiceErrorMessage.FETCH_POSITION_BY_ID_INVALID_RESPONSE);
433
532
  }
@@ -484,11 +583,8 @@ export class SocialService extends BaseDataService {
484
583
  headers: authHeaders,
485
584
  });
486
585
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_FEED_FAILED);
487
- const feedData = await response.json();
488
- if (!is(feedData, FeedResponseStruct)) {
489
- throw new Error(SocialServiceErrorMessage.FETCH_FEED_INVALID_RESPONSE);
490
- }
491
- return feedData;
586
+ const feedData = (await response.json());
587
+ return parseFeedResponse(feedData, SocialServiceErrorMessage.FETCH_FEED_INVALID_RESPONSE);
492
588
  },
493
589
  });
494
590
  return feedResponse;
@@ -541,11 +637,8 @@ export class SocialService extends BaseDataService {
541
637
  headers: authHeaders,
542
638
  });
543
639
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_TRADER_FEED_FAILED);
544
- const feedData = await response.json();
545
- if (!is(feedData, FeedResponseStruct)) {
546
- throw new Error(SocialServiceErrorMessage.FETCH_TRADER_FEED_INVALID_RESPONSE);
547
- }
548
- return feedData;
640
+ const feedData = (await response.json());
641
+ return parseFeedResponse(feedData, SocialServiceErrorMessage.FETCH_TRADER_FEED_INVALID_RESPONSE);
549
642
  },
550
643
  });
551
644
  return traderFeedResponse;
@@ -600,10 +693,7 @@ export class SocialService extends BaseDataService {
600
693
  });
601
694
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_TOKEN_FEED_FAILED);
602
695
  const feedData = (await response.json());
603
- if (!is(feedData, FeedResponseStruct)) {
604
- throw new Error(SocialServiceErrorMessage.FETCH_TOKEN_FEED_INVALID_RESPONSE);
605
- }
606
- return feedData;
696
+ return parseFeedResponse(feedData, SocialServiceErrorMessage.FETCH_TOKEN_FEED_INVALID_RESPONSE);
607
697
  },
608
698
  });
609
699
  return tokenFeedResponse;
@@ -633,7 +723,7 @@ export class SocialService extends BaseDataService {
633
723
  body: JSON.stringify({ emotion }),
634
724
  });
635
725
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.REACT_TO_COMMENT_FAILED);
636
- const metrics = await response.json();
726
+ const metrics = (await response.json());
637
727
  if (!is(metrics, CommentEngagementStruct)) {
638
728
  throw new Error(SocialServiceErrorMessage.REACT_TO_COMMENT_INVALID_RESPONSE);
639
729
  }
@@ -664,7 +754,7 @@ export class SocialService extends BaseDataService {
664
754
  headers: authHeaders,
665
755
  });
666
756
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.REMOVE_COMMENT_REACTION_FAILED);
667
- const metrics = await response.json();
757
+ const metrics = (await response.json());
668
758
  if (!is(metrics, CommentEngagementStruct)) {
669
759
  throw new Error(SocialServiceErrorMessage.REMOVE_COMMENT_REACTION_INVALID_RESPONSE);
670
760
  }
@@ -723,12 +813,57 @@ export class SocialService extends BaseDataService {
723
813
  },
724
814
  });
725
815
  }
816
+ /**
817
+ * Records that the current user is copying a trade from a social post, so
818
+ * the copy can be detected and attributed to the post's author.
819
+ *
820
+ * Calls `POST ${baseUrl}/copy-trade-intents`. Send swaps before submission
821
+ * with the Bridge quote id, and perps right after submission with the
822
+ * returned order id. The endpoint is idempotent per profile, origin, and
823
+ * execution id: it answers 201 for a new intent and 200 with the existing
824
+ * one on a retry.
825
+ *
826
+ * @param options - Options bag.
827
+ * @param options.origin - Comment the trade was copied from.
828
+ * @param options.copierAccount - CAIP-10 account placing the trade.
829
+ * @param options.execution - Swap quote id or perps order id.
830
+ * @returns The created or existing copy-trade intent.
831
+ */
832
+ async createCopyTradeIntent(options) {
833
+ const { origin, copierAccount, execution } = options;
834
+ return await this.fetchQuery({
835
+ queryKey: [
836
+ `${this.name}:createCopyTradeIntent`,
837
+ { origin, copierAccount, execution },
838
+ ],
839
+ staleTime: 0,
840
+ queryFn: async () => {
841
+ const url = `${this.#v1Url}/copy-trade-intents`;
842
+ const authHeaders = await this.#getAuthHeaders();
843
+ const response = await fetch(url, {
844
+ method: 'POST',
845
+ headers: { ...authHeaders, 'Content-Type': 'application/json' },
846
+ body: JSON.stringify({ origin, copierAccount, execution }),
847
+ });
848
+ _a.#throwIfNotOk(response, SocialServiceErrorMessage.CREATE_COPY_TRADE_INTENT_FAILED);
849
+ const intent = (await response.json());
850
+ if (!is(intent, CopyTradeIntentResponseStruct)) {
851
+ throw new Error(SocialServiceErrorMessage.CREATE_COPY_TRADE_INTENT_INVALID_RESPONSE);
852
+ }
853
+ return intent;
854
+ },
855
+ });
856
+ }
726
857
  /**
727
858
  * Fetches the list of traders the current user is following.
728
859
  *
729
860
  * Calls `GET ${baseUrl}/users/me/following`. The caller is identified
730
861
  * server-side from the JWT sub claim carried in the Authorization header.
731
862
  *
863
+ * Prefer {@link fetchMyFollowing} in new code (pairs with
864
+ * {@link fetchMyFollowers}). This method is unchanged and remains the
865
+ * `/users/me/following` read.
866
+ *
732
867
  * @returns The following response.
733
868
  */
734
869
  async fetchFollowing() {
@@ -740,7 +875,7 @@ export class SocialService extends BaseDataService {
740
875
  const authHeaders = await this.#getAuthHeaders();
741
876
  const response = await fetch(url, { headers: authHeaders });
742
877
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_FOLLOWING_FAILED);
743
- const followingData = await response.json();
878
+ const followingData = (await response.json());
744
879
  if (!is(followingData, FollowingResponseStruct)) {
745
880
  throw new Error(SocialServiceErrorMessage.FETCH_FOLLOWING_INVALID_RESPONSE);
746
881
  }
@@ -749,6 +884,43 @@ export class SocialService extends BaseDataService {
749
884
  });
750
885
  return followingResponse;
751
886
  }
887
+ /**
888
+ * Fetches the list of traders the current user is following.
889
+ *
890
+ * Alias of {@link fetchFollowing}. Calls `GET ${baseUrl}/users/me/following`.
891
+ * Preferred name in new code so `/users/me` reads are both `fetchMy*`.
892
+ *
893
+ * @returns The following response.
894
+ */
895
+ async fetchMyFollowing() {
896
+ return this.fetchFollowing();
897
+ }
898
+ /**
899
+ * Fetches the list of traders following the current user.
900
+ *
901
+ * Calls `GET ${baseUrl}/users/me/followers`. The caller is identified
902
+ * server-side from the JWT sub claim carried in the Authorization header.
903
+ *
904
+ * @returns The followers response.
905
+ */
906
+ async fetchMyFollowers() {
907
+ const followersResponse = await this.fetchQuery({
908
+ queryKey: [`${this.name}:fetchMyFollowers`],
909
+ staleTime: 0,
910
+ queryFn: async () => {
911
+ const url = `${this.#v1Url}/users/me/followers`;
912
+ const authHeaders = await this.#getAuthHeaders();
913
+ const response = await fetch(url, { headers: authHeaders });
914
+ _a.#throwIfNotOk(response, SocialServiceErrorMessage.FETCH_MY_FOLLOWERS_FAILED);
915
+ const followersData = (await response.json());
916
+ if (!is(followersData, FollowersResponseStruct)) {
917
+ throw new Error(SocialServiceErrorMessage.FETCH_MY_FOLLOWERS_INVALID_RESPONSE);
918
+ }
919
+ return followersData;
920
+ },
921
+ });
922
+ return followersResponse;
923
+ }
752
924
  /**
753
925
  * Follows one or more traders on behalf of the current user.
754
926
  *
@@ -773,7 +945,7 @@ export class SocialService extends BaseDataService {
773
945
  body: JSON.stringify({ targets }),
774
946
  });
775
947
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.FOLLOW_FAILED);
776
- const followData = await response.json();
948
+ const followData = (await response.json());
777
949
  if (!is(followData, FollowResponseStruct)) {
778
950
  throw new Error(SocialServiceErrorMessage.FOLLOW_INVALID_RESPONSE);
779
951
  }
@@ -810,7 +982,7 @@ export class SocialService extends BaseDataService {
810
982
  headers: authHeaders,
811
983
  });
812
984
  _a.#throwIfNotOk(response, SocialServiceErrorMessage.UNFOLLOW_FAILED);
813
- const unfollowData = await response.json();
985
+ const unfollowData = (await response.json());
814
986
  if (!is(unfollowData, UnfollowResponseStruct)) {
815
987
  throw new Error(SocialServiceErrorMessage.UNFOLLOW_INVALID_RESPONSE);
816
988
  }
@@ -1035,7 +1207,7 @@ export class SocialService extends BaseDataService {
1035
1207
  headers: authHeaders,
1036
1208
  });
1037
1209
  _a.#throwIfNotOk(response, failedMessage);
1038
- const positionsData = await response.json();
1210
+ const positionsData = (await response.json());
1039
1211
  if (!is(positionsData, PositionsResponseStruct)) {
1040
1212
  throw new Error(invalidMessage);
1041
1213
  }