Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
63c7623
Implement mobile feed functionality in mobile-client
marioinkfnd Jul 22, 2026
c738a8f
Refactor MobileFeedMargin type in clientTypes.ts
marioinkfnd Jul 23, 2026
a1e574e
Refactor MobileFeedTrade structure in dataMappers and types
marioinkfnd Jul 23, 2026
9a85db8
Refactor feed tests in mobile-client
marioinkfnd Jul 23, 2026
5b90148
Update mobile-client to version 0.27.0 and refactor publicQuery and q…
marioinkfnd Jul 23, 2026
9f173b1
Revert mobile-client version to 0.26.0 in package.json
marioinkfnd Jul 24, 2026
3253ae6
Refactor getFeed method in MobileClient to simplify request body cons…
marioinkfnd Jul 24, 2026
5e7ef8b
Update MobileFeedPosition to use BalanceSide type for direction
marioinkfnd Jul 24, 2026
2da5690
Enhance MobileFeedPosition type to use MobileFeedPositionDirection fo…
marioinkfnd Jul 24, 2026
3bf2cc3
Refactor MobileFeedPosition types for trade clarity
marioinkfnd Jul 24, 2026
48fd853
Refactor MobileServerFeedMargin type for clarity and consistency
marioinkfnd Jul 24, 2026
b2465f3
Refactor MobileClient and server types for improved clarity and type …
marioinkfnd Jul 24, 2026
0a6cd43
Refactor MobileClient and server types for improved clarity and consi…
marioinkfnd Jul 24, 2026
b10fc4f
Merge branch 'main' into mario/mobile-client-feed
marioinkfnd Jul 24, 2026
b27575d
Refactor mobile-client types and tests for improved clarity and consi…
marioinkfnd Jul 27, 2026
a83f5f1
Merge branch 'main' into mario/mobile-client-feed
marioinkfnd Jul 27, 2026
f1d67df
Refactor feed margin handling in mobile client tests and data mappers
marioinkfnd Jul 27, 2026
2aeef0c
Update feed test timeout for mobile client due to slow server-side qu…
marioinkfnd Jul 28, 2026
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
158 changes: 158 additions & 0 deletions apps/e2e/src/mobile-client/feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
MOBILE_ERROR_CODES,
MOBILE_FEED_MARGIN_MODES,
MOBILE_FEED_MAX_PAGE_SIZE,
MOBILE_FEED_MIN_NOTIONAL_FLOOR,
MOBILE_FEED_TRADE_POSITION_DIRECTIONS,
MOBILE_FEED_TRADE_POSITION_EFFECTS,
MobileFeedPage,
MobileFeedTrade,
MobileServerFailureError,
} from '@nadohq/mobile-client';
import assert from 'node:assert/strict';
import { before, describe, test } from 'node:test';
import {
assertArrayElements,
assertEnumMember,
assertHexString,
assertNumber,
assertString,
} from '../utils/assertions';
import { debugPrint } from '../utils/debugPrint';
import { delay } from '../utils/delay';
import { createTestContext } from '../utils/runWithContext';
import { TEST_DELAYS, TEST_TIMEOUTS } from '../utils/testConstants';
import { RunContext } from '../utils/types';

void describe(
'[mobile-client]: feed',
// Feed queries are currently slow server-side: an unfiltered page can take well over a minute, and even a
// filtered one takes ~20s. Stopgap until the backend feed query is indexed.
{ timeout: TEST_TIMEOUTS.LONG },
() => {
let tc: RunContext;

before(async () => {
await delay(TEST_DELAYS.LONG);
tc = createTestContext();
});

void test('fetches an unfiltered page of the global feed', async () => {
const page = await tc.mobile.getFeed();
debugPrint('Global feed page', page);
assertFeedPageShape(page);
});

void test('honors a minimum notional and page limit', async () => {
const limit = 5;
const page = await tc.mobile.getFeed({
minimumNotional: MOBILE_FEED_MIN_NOTIONAL_FLOOR,
limit,
});
debugPrint('Filtered feed page', page);
assertFeedPageShape(page);
assert.ok(
page.trades.length <= limit,
`feed page should respect the requested limit of ${limit}`,
);
assert.ok(
limit <= MOBILE_FEED_MAX_PAGE_SIZE,
'test limit should not exceed the backend page cap',
);
});

void test('rejects a malformed cursor with INVALID_FEED_CURSOR', async () => {
try {
await tc.mobile.getFeed({ cursor: 'not-a-feed-cursor' });
assert.fail('expected INVALID_FEED_CURSOR for a malformed cursor');
} catch (error) {
assert.ok(
error instanceof MobileServerFailureError,
'should throw MobileServerFailureError',
);
assert.equal(
error.responseData.error_code,
MOBILE_ERROR_CODES.INVALID_FEED_CURSOR,
);
}
});
},
);

/**
* Asserts the shape of a feed page and each of its trades.
*/
function assertFeedPageShape(page: MobileFeedPage): void {
assert.ok(Array.isArray(page.trades), 'feed page trades should be an array');
assert.ok(
page.nextCursor === null || typeof page.nextCursor === 'string',
'feed page nextCursor should be a string or null',
);
assertArrayElements(page.trades, assertFeedTradeShape, 'page.trades');
}

/**
* Asserts the shape of a single feed trade, covering identity enrichment, display-unit numbers, and the
* tagged margin/position objects.
*/
function assertFeedTradeShape(trade: MobileFeedTrade, label: string): void {
assertHexString(trade.orderDigest, `${label}.orderDigest`);
assertHexString(trade.subaccount, `${label}.subaccount`);
assertString(trade.username, `${label}.username`);
assertString(trade.displayName, `${label}.displayName`);
assert.ok(
trade.avatarUrl === null || typeof trade.avatarUrl === 'string',
`${label}.avatarUrl should be a string or null`,
);
assertNumber(trade.productId, `${label}.productId`);
assertFiniteNonNegativeNumber(trade.quantity, `${label}.quantity`);
assertFiniteNonNegativeNumber(trade.notional, `${label}.notional`);
assertFiniteNonNegativeNumber(trade.averagePrice, `${label}.averagePrice`);
assertEnumMember(
trade.margin.mode,
MOBILE_FEED_MARGIN_MODES,
`${label}.margin.mode`,
);
if (trade.margin.mode === 'cross') {
assert.equal(
trade.margin.estimatedLeverage,
undefined,
`${label}.margin.estimatedLeverage should be undefined on cross margin`,
);
} else if (trade.margin.estimatedLeverage !== undefined) {
assertNumber(
trade.margin.estimatedLeverage,
`${label}.margin.estimatedLeverage`,
);
}
assertEnumMember(
trade.position.direction,
MOBILE_FEED_TRADE_POSITION_DIRECTIONS,
`${label}.position.direction`,
);
assertEnumMember(
trade.position.effect,
MOBILE_FEED_TRADE_POSITION_EFFECTS,
`${label}.position.effect`,
);
assertNumber(trade.realizedPnl, `${label}.realizedPnl`);
assert.ok(
Number.isFinite(trade.realizedPnl),
`${label}.realizedPnl should be finite`,
);
assert.ok(
Number.isSafeInteger(trade.filledAt) && trade.filledAt >= 0,
`${label}.filledAt should be a non-negative safe integer`,
);
}

/**
* Asserts that a value is a finite, non-negative plain number.
*/
function assertFiniteNonNegativeNumber(value: unknown, label: string): void {
assertNumber(value, label);
assert.ok(
Number.isFinite(value) && (value as number) >= 0,
`${label} should be a finite non-negative number`,
);
}
76 changes: 49 additions & 27 deletions packages/mobile-client/src/MobileClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from '@nadohq/shared';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import {
mapMobileFeedPage,
mapMobileIdentity,
mapMobileNotificationPreferences,
mapMobileNotificationPreferencesToServer,
Expand All @@ -17,12 +18,14 @@ import {
MobileSignedRequest,
} from './signing';
import {
GetMobileFeedParams,
GetMobileNotificationPreferencesParams,
GetMobilePublicProfileParams,
GetMobileRegisteredDevicesParams,
GetMobileSelfIdentityParams,
GetMobileUsernameAvailabilityParams,
MobileClaimUsernameParams,
MobileFeedPage,
MobileIdentity,
MobileNotificationPreferences,
MobilePublicProfile,
Expand All @@ -39,14 +42,15 @@ import { MobileServerFailureError } from './types/MobileServerFailureError';
import { MobileServerSuccessResponse } from './types/serverBaseTypes';
import { MobileServerExecuteResult } from './types/serverExecuteTypes';
import {
MobileServerNotificationPreferencesResponse,
MobileServerProfileRequest,
MobileServerProfileResponse,
MobileServerRegisteredDevicesResponse,
MobileServerSelfIdentityResponse,
MobileServerUsernameAvailabilityRequest,
MobileServerUsernameAvailabilityResponse,
MobileServerPublicQueryRequest,
MobileServerPublicQuerySuccessResponse,
MobileServerSignedQuerySuccessResponse,
} from './types/serverQueryTypes';
import {
MobileServerPublicQueryRequestByType,
MobileServerPublicQueryRequestType,
MobileServerSignedQueryRequestType,
} from './types/serverRequestTypes';
import {
isMobileServerFailureResponse,
isMobileServerSuccessResponse,
Expand Down Expand Up @@ -108,12 +112,11 @@ export class MobileClient {
async getUsernameAvailability(
params: GetMobileUsernameAvailabilityParams,
): Promise<MobileUsernameAvailability> {
const body: MobileServerUsernameAvailabilityRequest = {
const body: MobileServerPublicQueryRequest<'username_availability'> = {
type: 'username_availability',
display_name: params.displayName,
};
const data =
await this.publicQuery<MobileServerUsernameAvailabilityResponse>(body);
const data = await this.publicQuery(body);
return { username: data.username, available: data.available };
}

Expand All @@ -126,14 +129,36 @@ export class MobileClient {
async getPublicProfile(
params: GetMobilePublicProfileParams,
): Promise<MobilePublicProfile> {
const body: MobileServerProfileRequest = {
const body: MobileServerPublicQueryRequest<'profile'> = {
type: 'profile',
username: params.username,
};
const data = await this.publicQuery<MobileServerProfileResponse>(body);
const data = await this.publicQuery(body);
return mapMobilePublicProfile(data.profile);
}

/**
* Fetches a page of the global trade feed: public, named, perpetual trades, newest first, optionally
* filtered by a whole-dollar minimum notional (omitted means unfiltered). The feed is best-effort rather
* than authoritative history, and pagination is live (not snapshot), so deduplicate pages by
* {@link MobileFeedTrade.orderDigest}.
*
* @throws {MobileServerFailureError} With error code `INVALID_FEED_FILTER` if `minimumNotional` or
* `limit` is outside its allowed domain (fix the request; do not retry unchanged), or
* `INVALID_FEED_CURSOR` if the cursor is malformed or was issued for a different `minimumNotional`
* (discard the cursor and restart from the first page).
*/
async getFeed(params: GetMobileFeedParams = {}): Promise<MobileFeedPage> {
const body: MobileServerPublicQueryRequest<'feed'> = {
type: 'feed',
minimum_notional: params.minimumNotional,
limit: params.limit,
cursor: params.cursor,
};
const data = await this.publicQuery(body);
return mapMobileFeedPage(data);
}

/*
Signed queries
*/
Expand All @@ -150,8 +175,7 @@ export class MobileClient {
params,
{},
);
const data =
await this.query<MobileServerSelfIdentityResponse>(signedRequest);
const data = await this.query<'self_identity'>(signedRequest);
return data.identity ? mapMobileIdentity(data.identity) : null;
}

Expand All @@ -167,10 +191,7 @@ export class MobileClient {
params,
{},
);
const data =
await this.query<MobileServerNotificationPreferencesResponse>(
signedRequest,
);
const data = await this.query<'notification_preferences'>(signedRequest);
return mapMobileNotificationPreferences(data.preferences);
}

Expand All @@ -185,8 +206,7 @@ export class MobileClient {
params,
{},
);
const data =
await this.query<MobileServerRegisteredDevicesResponse>(signedRequest);
const data = await this.query<'registered_devices'>(signedRequest);
return data.devices.map(mapMobileRegisteredDevice);
}

Expand Down Expand Up @@ -318,9 +338,11 @@ export class MobileClient {
return buildSignedMobileRequest({ ...params, walletClient, inner });
}

protected async publicQuery<TResponse extends { status: 'success' }>(
body: object,
): Promise<TResponse> {
// Spelled out as an intersection rather than `MobileServerPublicQueryRequest<T>`: the latter is an indexed
// access on a mapped type, which is not an inference site, so `T` would always widen to the full union.
protected async publicQuery<T extends MobileServerPublicQueryRequestType>(
body: { type: T } & MobileServerPublicQueryRequestByType[T],
): Promise<MobileServerPublicQuerySuccessResponse<T>> {
const response = await this.axiosInstance.post<unknown>(
`${this.opts.url}/mobile/public_query`,
body,
Expand All @@ -330,12 +352,12 @@ export class MobileClient {
this.checkServerStatus(response);

// checkServerStatus throws on failure responses so the cast to the success response is acceptable here
return response.data as TResponse;
return response.data as MobileServerPublicQuerySuccessResponse<T>;
}

protected async query<TResponse extends { status: 'success' }>(
protected async query<T extends MobileServerSignedQueryRequestType>(
body: MobileSignedRequest,
): Promise<TResponse> {
): Promise<MobileServerSignedQuerySuccessResponse<T>> {
const response = await this.axiosInstance.post<unknown>(
`${this.opts.url}/mobile/query`,
body,
Expand All @@ -345,7 +367,7 @@ export class MobileClient {
this.checkServerStatus(response);

// checkServerStatus throws on failure responses so the cast to the success response is acceptable here
return response.data as TResponse;
return response.data as MobileServerSignedQuerySuccessResponse<T>;
}

protected async execute(
Expand Down
40 changes: 40 additions & 0 deletions packages/mobile-client/src/dataMappers.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import {
MobileFeedPage,
MobileFeedTrade,
MobileIdentity,
MobileNotificationPreferenceScope,
MobileNotificationPreferences,
MobilePublicProfile,
MobileRegisteredDevice,
} from './types/clientTypes';
import {
MobileServerFeedTrade,
MobileServerIdentity,
MobileServerNotificationPreferenceScope,
MobileServerNotificationPreferences,
MobileServerProfile,
MobileServerRegisteredDevice,
} from './types/serverModelTypes';
import { MobileServerFeedResponse } from './types/serverQueryTypes';

/**
* Maps a server-side identity (snake_case) to its client-side (camelCase) representation.
Expand Down Expand Up @@ -90,6 +94,42 @@ function mapMobileNotificationPreferenceScopeToServer(
return { type: 'product', product_id: scope.productId };
}

/**
* Maps a server-side feed trade (snake_case) to its client-side (camelCase) representation.
*/
function mapMobileFeedTrade(server: MobileServerFeedTrade): MobileFeedTrade {
return {
orderDigest: server.order_digest,
subaccount: server.subaccount,
username: server.username,
displayName: server.display_name,
avatarUrl: server.avatar_url,
productId: server.product_id,
quantity: server.quantity,
notional: server.notional,
averagePrice: server.average_price,
margin: {
mode: server.margin.mode,
estimatedLeverage: server.margin.estimated_leverage,
},
position: server.position,
realizedPnl: server.realized_pnl,
filledAt: server.filled_at_ms,
};
}

/**
* Maps a server-side feed response to a client-side {@link MobileFeedPage}.
*/
export function mapMobileFeedPage(
server: MobileServerFeedResponse,
): MobileFeedPage {
return {
trades: server.trades.map(mapMobileFeedTrade),
nextCursor: server.next_cursor,
};
}

/**
* Maps a server-side registered device (snake_case) to its client-side (camelCase) representation.
*/
Expand Down
Loading
Loading