Skip to content

Commit 2dd6aeb

Browse files
Bigshmowclaude
andauthored
Feat/social controllers leaderboard opt out in (#9354)
## Explanation Adds `optOutOfLeaderboard` and `optInToLeaderboard` to `SocialController` and `SocialService`, with their corresponding messenger actions. `@metamask/social-controllers` previously had no way to opt a user out of (or back into) the Top Traders leaderboard; the mobile client needs these to remove/restore a user from the global, Clicker-backed leaderboard. Each method `POST`s to the existing social-api endpoint (`/leaderboard/opt-out`, `/leaderboard/opt-in`) with the JWT already attached by `SocialService`, and resolves to `void` on the backend's `204`. Purely additive — no existing methods, types, or behavior change. ## References - Related to [TSA-830](https://consensyssoftware.atlassian.net/browse/TSA-830) - leaderboard opt-out: client flow + AUS persistence. Consumed by the MetaMask mobile client. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them [TSA-830]: https://consensyssoftware.atlassian.net/browse/TSA-830?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Purely additive API surface with tests; no changes to existing methods, state, or auth flow beyond new leaderboard preference calls. > > **Overview** > Adds **`optOutOfLeaderboard`** and **`optInToLeaderboard`** to `@metamask/social-controllers` so clients (e.g. mobile) can remove or restore the signed-in user on the Top Traders / PnL leaderboard. > > **`SocialService`** issues JWT-authenticated **`POST`** requests to **`/api/v1/leaderboard/opt-out`** and **`/api/v1/leaderboard/opt-in`**, treats success as **`204`**, and surfaces failures via new **`LEADERBOARD_OPT_OUT_FAILED`** / **`LEADERBOARD_OPT_IN_FAILED`** messages. **`SocialController`** exposes the same operations as thin messenger delegates (no controller state changes). Messenger action types, package exports, changelog, and unit tests are updated accordingly. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bdef2c4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0997c6a commit 2dd6aeb

9 files changed

Lines changed: 268 additions & 3 deletions

packages/social-controllers/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `optOutOfLeaderboard` and `optInToLeaderboard` methods to `SocialController` and `SocialService`, with corresponding messenger actions (`SocialController:optOutOfLeaderboard`, `SocialController:optInToLeaderboard`, `SocialService:optOutOfLeaderboard`, `SocialService:optInToLeaderboard`). These call `POST /leaderboard/opt-out` and `POST /leaderboard/opt-in` respectively (JWT-authed, `204 No Content` on success) ([#9354](https://github.com/MetaMask/core/pull/9354))
13+
1014
### Changed
1115

1216
- Bump `@metamask/controller-utils` from `^12.2.0` to `^12.3.0` ([#9218](https://github.com/MetaMask/core/pull/9218))

packages/social-controllers/src/SocialController-method-action-types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,29 @@ export type SocialControllerUpdateFollowingAction = {
5656
handler: SocialController['updateFollowing'];
5757
};
5858

59+
/**
60+
* Opts the current user out of the PnL leaderboard.
61+
*/
62+
export type SocialControllerOptOutOfLeaderboardAction = {
63+
type: `SocialController:optOutOfLeaderboard`;
64+
handler: SocialController['optOutOfLeaderboard'];
65+
};
66+
67+
/**
68+
* Opts the current user back into the PnL leaderboard.
69+
*/
70+
export type SocialControllerOptInToLeaderboardAction = {
71+
type: `SocialController:optInToLeaderboard`;
72+
handler: SocialController['optInToLeaderboard'];
73+
};
74+
5975
/**
6076
* Union of all SocialController action types.
6177
*/
6278
export type SocialControllerMethodActions =
6379
| SocialControllerUpdateLeaderboardAction
6480
| SocialControllerFollowTraderAction
6581
| SocialControllerUnfollowTraderAction
66-
| SocialControllerUpdateFollowingAction;
82+
| SocialControllerUpdateFollowingAction
83+
| SocialControllerOptOutOfLeaderboardAction
84+
| SocialControllerOptInToLeaderboardAction;

packages/social-controllers/src/SocialController.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ function getMessenger(rootMessenger: RootMessenger): SocialControllerMessenger {
5858
'SocialService:follow',
5959
'SocialService:unfollow',
6060
'SocialService:fetchFollowing',
61+
'SocialService:optOutOfLeaderboard',
62+
'SocialService:optInToLeaderboard',
6163
],
6264
events: [],
6365
messenger,
@@ -499,6 +501,104 @@ describe('SocialController', () => {
499501
});
500502
});
501503

504+
describe('optOutOfLeaderboard', () => {
505+
it('delegates to the service via messenger and resolves to void', async () => {
506+
const rootMessenger = getRootMessenger();
507+
const optOut = jest.fn().mockResolvedValue(undefined);
508+
mockServiceAction(
509+
rootMessenger,
510+
'SocialService:optOutOfLeaderboard',
511+
optOut,
512+
);
513+
514+
const { controller } = createController({ rootMessenger });
515+
516+
const result = await controller.optOutOfLeaderboard();
517+
518+
expect(optOut).toHaveBeenCalledTimes(1);
519+
expect(result).toBeUndefined();
520+
});
521+
522+
it('is callable via messenger action', async () => {
523+
const rootMessenger = getRootMessenger();
524+
mockServiceAction(
525+
rootMessenger,
526+
'SocialService:optOutOfLeaderboard',
527+
jest.fn().mockResolvedValue(undefined),
528+
);
529+
530+
const { messenger } = createController({ rootMessenger });
531+
532+
expect(
533+
await messenger.call('SocialController:optOutOfLeaderboard'),
534+
).toBeUndefined();
535+
});
536+
537+
it('propagates service errors', async () => {
538+
const rootMessenger = getRootMessenger();
539+
mockServiceAction(
540+
rootMessenger,
541+
'SocialService:optOutOfLeaderboard',
542+
jest.fn().mockRejectedValue(new Error('opt-out failed')),
543+
);
544+
545+
const { controller } = createController({ rootMessenger });
546+
547+
await expect(controller.optOutOfLeaderboard()).rejects.toThrow(
548+
'opt-out failed',
549+
);
550+
});
551+
});
552+
553+
describe('optInToLeaderboard', () => {
554+
it('delegates to the service via messenger and resolves to void', async () => {
555+
const rootMessenger = getRootMessenger();
556+
const optIn = jest.fn().mockResolvedValue(undefined);
557+
mockServiceAction(
558+
rootMessenger,
559+
'SocialService:optInToLeaderboard',
560+
optIn,
561+
);
562+
563+
const { controller } = createController({ rootMessenger });
564+
565+
const result = await controller.optInToLeaderboard();
566+
567+
expect(optIn).toHaveBeenCalledTimes(1);
568+
expect(result).toBeUndefined();
569+
});
570+
571+
it('is callable via messenger action', async () => {
572+
const rootMessenger = getRootMessenger();
573+
mockServiceAction(
574+
rootMessenger,
575+
'SocialService:optInToLeaderboard',
576+
jest.fn().mockResolvedValue(undefined),
577+
);
578+
579+
const { messenger } = createController({ rootMessenger });
580+
581+
expect(
582+
await messenger.call('SocialController:optInToLeaderboard'),
583+
).toBeUndefined();
584+
});
585+
586+
it('propagates service errors', async () => {
587+
const rootMessenger = getRootMessenger();
588+
mockServiceAction(
589+
rootMessenger,
590+
'SocialService:optInToLeaderboard',
591+
jest.fn().mockRejectedValue(new Error('opt-in failed')),
592+
);
593+
594+
const { controller } = createController({ rootMessenger });
595+
596+
await expect(controller.optInToLeaderboard()).rejects.toThrow(
597+
'opt-in failed',
598+
);
599+
});
600+
});
601+
502602
describe('error propagation', () => {
503603
it('propagates service errors from updateLeaderboard', async () => {
504604
const rootMessenger = getRootMessenger();

packages/social-controllers/src/SocialController.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import type {
2222
SocialServiceFetchFollowingAction,
2323
SocialServiceFetchLeaderboardAction,
2424
SocialServiceFollowAction,
25+
SocialServiceOptInToLeaderboardAction,
26+
SocialServiceOptOutOfLeaderboardAction,
2527
SocialServiceUnfollowAction,
2628
} from './SocialService-method-action-types';
2729

@@ -32,6 +34,8 @@ const MESSENGER_EXPOSED_METHODS = [
3234
'followTrader',
3335
'unfollowTrader',
3436
'updateFollowing',
37+
'optOutOfLeaderboard',
38+
'optInToLeaderboard',
3539
] as const;
3640

3741
// === ACTION TYPES ===
@@ -60,7 +64,9 @@ type AllowedActions =
6064
| SocialServiceFetchLeaderboardAction
6165
| SocialServiceFollowAction
6266
| SocialServiceUnfollowAction
63-
| SocialServiceFetchFollowingAction;
67+
| SocialServiceFetchFollowingAction
68+
| SocialServiceOptOutOfLeaderboardAction
69+
| SocialServiceOptInToLeaderboardAction;
6470

6571
type AllowedEvents = never;
6672

@@ -264,4 +270,18 @@ export class SocialController extends BaseController<
264270

265271
return followingResponse;
266272
}
273+
274+
/**
275+
* Opts the current user out of the PnL leaderboard.
276+
*/
277+
async optOutOfLeaderboard(): Promise<void> {
278+
await this.messenger.call('SocialService:optOutOfLeaderboard');
279+
}
280+
281+
/**
282+
* Opts the current user back into the PnL leaderboard.
283+
*/
284+
async optInToLeaderboard(): Promise<void> {
285+
await this.messenger.call('SocialService:optInToLeaderboard');
286+
}
267287
}

packages/social-controllers/src/SocialService-method-action-types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ export type SocialServiceUnfollowAction = {
141141
handler: SocialService['unfollow'];
142142
};
143143

144+
/**
145+
* Opts the current user out of the PnL leaderboard.
146+
*/
147+
export type SocialServiceOptOutOfLeaderboardAction = {
148+
type: `SocialService:optOutOfLeaderboard`;
149+
handler: SocialService['optOutOfLeaderboard'];
150+
};
151+
152+
/**
153+
* Opts the current user back into the PnL leaderboard.
154+
*/
155+
export type SocialServiceOptInToLeaderboardAction = {
156+
type: `SocialService:optInToLeaderboard`;
157+
handler: SocialService['optInToLeaderboard'];
158+
};
159+
144160
/**
145161
* Union of all SocialService action types.
146162
*/
@@ -153,4 +169,6 @@ export type SocialServiceMethodActions =
153169
| SocialServiceFetchPositionByIdAction
154170
| SocialServiceFetchFollowingAction
155171
| SocialServiceFollowAction
156-
| SocialServiceUnfollowAction;
172+
| SocialServiceUnfollowAction
173+
| SocialServiceOptOutOfLeaderboardAction
174+
| SocialServiceOptInToLeaderboardAction;

packages/social-controllers/src/SocialService.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,4 +1029,54 @@ describe('SocialService', () => {
10291029
);
10301030
});
10311031
});
1032+
1033+
describe('optOutOfLeaderboard', () => {
1034+
it('sends POST to /leaderboard/opt-out with the bearer token and resolves to void', async () => {
1035+
mockFetch.mockResolvedValue({ ok: true, status: 204 });
1036+
1037+
const service = createService();
1038+
const result = await service.optOutOfLeaderboard();
1039+
1040+
expect(result).toBeUndefined();
1041+
expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/leaderboard/opt-out`, {
1042+
method: 'POST',
1043+
headers: { Authorization: `Bearer ${MOCK_TOKEN}` },
1044+
});
1045+
});
1046+
1047+
it('throws HttpError on non-ok response', async () => {
1048+
mockFetch.mockResolvedValue({ ok: false, status: 401 });
1049+
1050+
const service = createService();
1051+
1052+
await expect(service.optOutOfLeaderboard()).rejects.toThrow(
1053+
`${SocialServiceErrorMessage.LEADERBOARD_OPT_OUT_FAILED}: 401`,
1054+
);
1055+
});
1056+
});
1057+
1058+
describe('optInToLeaderboard', () => {
1059+
it('sends POST to /leaderboard/opt-in with the bearer token and resolves to void', async () => {
1060+
mockFetch.mockResolvedValue({ ok: true, status: 204 });
1061+
1062+
const service = createService();
1063+
const result = await service.optInToLeaderboard();
1064+
1065+
expect(result).toBeUndefined();
1066+
expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/leaderboard/opt-in`, {
1067+
method: 'POST',
1068+
headers: { Authorization: `Bearer ${MOCK_TOKEN}` },
1069+
});
1070+
});
1071+
1072+
it('throws HttpError on non-ok response', async () => {
1073+
mockFetch.mockResolvedValue({ ok: false, status: 500 });
1074+
1075+
const service = createService();
1076+
1077+
await expect(service.optInToLeaderboard()).rejects.toThrow(
1078+
`${SocialServiceErrorMessage.LEADERBOARD_OPT_IN_FAILED}: 500`,
1079+
);
1080+
});
1081+
});
10321082
});

packages/social-controllers/src/SocialService.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ const MESSENGER_EXPOSED_METHODS = [
186186
'fetchPositionById',
187187
'follow',
188188
'unfollow',
189+
'optOutOfLeaderboard',
190+
'optInToLeaderboard',
189191
] as const;
190192

191193
export type SocialServiceActions =
@@ -584,6 +586,52 @@ export class SocialService extends BaseDataService<
584586
return unfollowResponse;
585587
}
586588

589+
/**
590+
* Opts the current user out of the PnL leaderboard.
591+
*/
592+
async optOutOfLeaderboard(): Promise<void> {
593+
await this.fetchQuery({
594+
queryKey: [`${this.name}:optOutOfLeaderboard`],
595+
staleTime: 0,
596+
queryFn: async () => {
597+
const url = `${this.#v1Url}/leaderboard/opt-out`;
598+
const authHeaders = await this.#getAuthHeaders();
599+
const response = await fetch(url, {
600+
method: 'POST',
601+
headers: authHeaders,
602+
});
603+
SocialService.#throwIfNotOk(
604+
response,
605+
SocialServiceErrorMessage.LEADERBOARD_OPT_OUT_FAILED,
606+
);
607+
return null;
608+
},
609+
});
610+
}
611+
612+
/**
613+
* Opts the current user back into the PnL leaderboard.
614+
*/
615+
async optInToLeaderboard(): Promise<void> {
616+
await this.fetchQuery({
617+
queryKey: [`${this.name}:optInToLeaderboard`],
618+
staleTime: 0,
619+
queryFn: async () => {
620+
const url = `${this.#v1Url}/leaderboard/opt-in`;
621+
const authHeaders = await this.#getAuthHeaders();
622+
const response = await fetch(url, {
623+
method: 'POST',
624+
headers: authHeaders,
625+
});
626+
SocialService.#throwIfNotOk(
627+
response,
628+
SocialServiceErrorMessage.LEADERBOARD_OPT_IN_FAILED,
629+
);
630+
return null;
631+
},
632+
});
633+
}
634+
587635
/**
588636
* Shared helper for fetching open/closed positions.
589637
*

packages/social-controllers/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export {
1313

1414
export type {
1515
SocialControllerFollowTraderAction,
16+
SocialControllerOptInToLeaderboardAction,
17+
SocialControllerOptOutOfLeaderboardAction,
1618
SocialControllerUnfollowTraderAction,
1719
SocialControllerUpdateFollowingAction,
1820
SocialControllerUpdateLeaderboardAction,
@@ -34,6 +36,8 @@ export type {
3436
SocialServiceFetchPositionByIdAction,
3537
SocialServiceFetchTraderProfileAction,
3638
SocialServiceFollowAction,
39+
SocialServiceOptInToLeaderboardAction,
40+
SocialServiceOptOutOfLeaderboardAction,
3741
SocialServiceUnfollowAction,
3842
} from './SocialService-method-action-types';
3943

packages/social-controllers/src/social-constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ export const SocialServiceErrorMessage = {
3030
FETCH_POSITION_BY_ID_FAILED: 'SocialService: Position request failed',
3131
FETCH_POSITION_BY_ID_INVALID_RESPONSE:
3232
'SocialService: Position returned invalid response',
33+
LEADERBOARD_OPT_OUT_FAILED:
34+
'SocialService: Leaderboard opt-out request failed',
35+
LEADERBOARD_OPT_IN_FAILED: 'SocialService: Leaderboard opt-in request failed',
3336
} as const;

0 commit comments

Comments
 (0)