Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/social-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `action` to `Trade` and optional `isOpen` to `Position`, plus the `TradeAction` type ([#9793](https://github.com/MetaMask/core/pull/9793))
- `action` is the fill's position-lifecycle stage (`opened` / `added` / `reduced` / `closed`), computed by the social-api. Unlike `intent`, it separates a partial exit from a full close.
- Both are optional so responses from a social-api deployment that predates them still validate.

### Changed

- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754))
Expand Down
73 changes: 73 additions & 0 deletions packages/social-controllers/src/SocialService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,79 @@ describe('SocialService', () => {
expect(result.positions[0].trades[0].classification).toBeNull();
});

it('passes the position lifecycle fields through', async () => {
const position = {
...mockPosition,
isOpen: true,
trades: [{ ...mockTrade, action: 'added' }],
};
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
positions: [position],
pagination: { hasMore: false },
}),
});

const service = createService();
const result = await service.fetchOpenPositions({
addressOrId: '0x1234',
});

expect(result.positions[0].isOpen).toBe(true);
expect(result.positions[0].trades[0].action).toBe('added');
});

// The social-api ships independently of this package, so a response from a
// deployment that predates `action`/`isOpen` must still validate — clients
// fall back to deriving the stage from the trade history.
it('accepts a position without the lifecycle fields', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
positions: [mockPosition],
pagination: { hasMore: false },
}),
});

const service = createService();
const result = await service.fetchOpenPositions({
addressOrId: '0x1234',
});

expect(result.positions[0].isOpen).toBeUndefined();
expect(result.positions[0].trades[0].action).toBeUndefined();
});

it('rejects an unknown trade action', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
positions: [
{
...mockPosition,
trades: [{ ...mockTrade, action: 'flipped' }],
},
],
pagination: { hasMore: false },
}),
});

const service = createService();

await expect(
service.fetchOpenPositions({ addressOrId: '0x1234' }),
).rejects.toThrow(
SocialServiceErrorMessage.FETCH_OPEN_POSITIONS_INVALID_RESPONSE,
);
});

it('rejects an invalid perpPositionType', async () => {
mockFetch.mockResolvedValue({
ok: true,
Expand Down
1 change: 1 addition & 0 deletions packages/social-controllers/src/SocialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const PositionStruct = structType({
tokenAddress: string(),
chain: string(),
positionAmount: number(),
isOpen: optional(boolean()),
boughtUsd: number(),
soldUsd: number(),
realizedPnl: number(),
Expand Down
1 change: 1 addition & 0 deletions packages/social-controllers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type {
SocialControllerState,
SocialHandles,
Trade,
TradeAction,
TraderProfile,
TraderProfileResponse,
TraderStats,
Expand Down
24 changes: 24 additions & 0 deletions packages/social-controllers/src/social-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,26 @@ export type SocialHandles = {
lens?: string | null;
};

/**
* Where a fill sits in its position's lifecycle.
*
* Deliberately asset-agnostic so the classification is computed once, upstream:
* clients render `opened / added / reduced / closed` for perps and
* `bought / bought more / sold some / sold all` for spot from the same value.
*
* Distinct from `intent`, which only says whether the fill grew or shrank the
* position — `intent: 'exit'` covers both a partial trim and a full close.
*/
export type TradeAction = 'opened' | 'added' | 'reduced' | 'closed';

export const TradeStruct = structType({
direction: enums(['buy', 'sell']),
intent: enums(['enter', 'exit']),
/**
* Lifecycle stage of this fill. Absent on responses from a social-api that
* predates the field, so treat it as a hint and keep a client-side fallback.
*/
action: optional(enums(['opened', 'added', 'reduced', 'closed'])),
category: optional(string()),
/** High-level trade classification. `null` when Clicker does not classify. */
classification: optional(
Expand Down Expand Up @@ -160,6 +177,13 @@ export type Position = {
tokenAddress: string;
chain: string;
positionAmount: number;
/**
* Whether the position still carries exposure. Clicker's own verdict, which
* beats a `positionAmount === 0` check: it survives precision dust and
* distinguishes "no position" from "a position of size ~0". Absent on
* responses from a social-api that predates the field.
*/
isOpen?: boolean;
boughtUsd: number;
soldUsd: number;
realizedPnl: number;
Expand Down
Loading