Skip to content
Open
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
149 changes: 142 additions & 7 deletions @shared/api/__tests__/internal.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar";
import { Networks } from "stellar-sdk";
import {
FUTURENET_NETWORK_DETAILS,
MAINNET_NETWORK_DETAILS,
TESTNET_NETWORK_DETAILS,
} from "@shared/constants/stellar";
import * as GetLedgerKeyAccounts from "../helpers/getLedgerKeyAccounts";
import * as internalApi from "../internal";

Expand Down Expand Up @@ -146,11 +151,14 @@ describe("internalApi", () => {
it("excludes contract-ID issuers from the indexer request", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices([
"native",
"USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM",
"DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ",
]);
await internalApi.getTokenPrices(
[
"native",
"USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM",
"DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ",
],
MAINNET_NETWORK_DETAILS,
);

const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const body = JSON.parse(requestInit.body as string);
Expand All @@ -163,11 +171,138 @@ describe("internalApi", () => {
it("excludes liquidity-pool IDs from the indexer request", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices(["native", "abc123:lp"]);
await internalApi.getTokenPrices(
["native", "abc123:lp"],
MAINNET_NETWORK_DETAILS,
);

const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const body = JSON.parse(requestInit.body as string);
expect(body.tokens).toEqual(["native"]);
});

it("targets the v2 endpoint with the network query param", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices(["native"], TESTNET_NETWORK_DETAILS);

const requestUrl = fetchSpy.mock.calls[0][0] as string;
expect(requestUrl).toContain("/token-prices");
expect(requestUrl).toContain("network=TESTNET");
});

it("derives the price network from the passphrase for custom networks", async () => {
const fetchSpy = mockFetchOk();

// Custom network stored as STANDALONE but sharing the pubnet passphrase
// must still resolve to PUBLIC and hit the endpoint.
await internalApi.getTokenPrices(["native"], {
...MAINNET_NETWORK_DETAILS,
network: "STANDALONE",
networkName: "Custom Pubnet",
networkPassphrase: Networks.PUBLIC,
});

expect(fetchSpy).toHaveBeenCalled();
const requestUrl = fetchSpy.mock.calls[0][0] as string;
expect(requestUrl).toContain("network=PUBLIC");
});

it("skips the request on unsupported networks", async () => {
const fetchSpy = mockFetchOk();

const prices = await internalApi.getTokenPrices(
["native"],
FUTURENET_NETWORK_DETAILS,
);

expect(fetchSpy).not.toHaveBeenCalled();
expect(prices).toEqual({});
});

it("skips the request when every token is filtered out", async () => {
const fetchSpy = mockFetchOk();

const prices = await internalApi.getTokenPrices(
[
"abc123:lp",
"DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ",
],
MAINNET_NETWORK_DETAILS,
);

expect(fetchSpy).not.toHaveBeenCalled();
expect(prices).toEqual({});
});
});

describe("getTokenPrices v1 endpoint (useV2 = false)", () => {
const mockFetchOk = () =>
jest.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ data: {} }),
} as unknown as Response);

it("targets the v1 endpoint without a network query param", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices(
["native"],
TESTNET_NETWORK_DETAILS,
false,
);

const requestUrl = fetchSpy.mock.calls[0][0] as string;
expect(requestUrl).toContain("/token-prices");
expect(requestUrl).not.toContain("network=");
});

it("still filters LP IDs and contract-ID issuers from the request", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices(
[
"native",
"abc123:lp",
"DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ",
],
MAINNET_NETWORK_DETAILS,
false,
);

const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const body = JSON.parse(requestInit.body as string);
expect(body.tokens).toEqual(["native"]);
});

it("does NOT skip unsupported networks (unlike v2)", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices(
["native"],
FUTURENET_NETWORK_DETAILS,
false,
);

expect(fetchSpy).toHaveBeenCalled();
});

it("does NOT skip when every token is filtered out (unlike v2)", async () => {
const fetchSpy = mockFetchOk();

await internalApi.getTokenPrices(
[
"abc123:lp",
"DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ",
],
MAINNET_NETWORK_DETAILS,
false,
);

expect(fetchSpy).toHaveBeenCalled();
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const body = JSON.parse(requestInit.body as string);
expect(body.tokens).toEqual([]);
});
});
});
34 changes: 32 additions & 2 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
DEFAULT_NETWORKS,
NetworkDetails,
NETWORKS,
PASSPHRASE_TO_PRICE_NETWORK,
} from "../constants/stellar";
import { SERVICE_TYPES } from "../constants/services";
import { isDev } from "../helpers/dev";
Expand Down Expand Up @@ -599,13 +600,42 @@ export const getAccountIndexerBalances = async ({
};
};

export const getTokenPrices = async (tokens: string[]) => {
export const getTokenPrices = async (
tokens: string[],
networkDetails: NetworkDetails,
// Defaults to the v2 endpoint. Callers pass the `use_token_prices_v2` feature
// flag so Amplitude can roll back to the v1 endpoint without a release.
useV2 = true,
): Promise<ApiTokenPrices> => {
Comment thread
Copilot marked this conversation as resolved.
// NOTE: API does not accept LP IDs or custom tokens
const filteredTokens = tokens.filter((tokenId) => {
const asset = getAssetFromCanonical(tokenId);
return !tokenId.includes(":lp") && !isContractId(asset.issuer);
});
Comment thread
aristidesstaffieri marked this conversation as resolved.
const url = new URL(`${INDEXER_URL}/token-prices`);

let url: URL;
if (useV2) {
// The v2 token-prices endpoint only supports pubnet and testnet. Derive the
// price network from the passphrase rather than networkDetails.network so
// that custom networks sharing the pubnet/testnet passphrase (stored as
// STANDALONE) still resolve to the correct supported network. Anything else
// (Futurenet, custom passphrases) is skipped to avoid a guaranteed error and
// Sentry noise.
const priceNetwork =
PASSPHRASE_TO_PRICE_NETWORK[networkDetails.networkPassphrase];
if (!priceNetwork) {
return {};
}
// Nothing priceable left after filtering, so skip the request rather than
// POST an empty tokens array and risk a 4xx that surfaces as an error.
if (!filteredTokens.length) {
return {};
}
url = new URL(`${INDEXER_V2_URL}/token-prices`);
url.searchParams.append("network", priceNetwork);
Comment thread
aristidesstaffieri marked this conversation as resolved.
} else {
url = new URL(`${INDEXER_URL}/token-prices`);
}
const options = {
method: "POST",
headers: {
Expand Down
8 changes: 8 additions & 0 deletions @shared/constants/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,11 @@ export const PASSPHRASE_TO_NETWORK_NAME: Record<string, string> = {
[Networks.TESTNET]: NETWORK_NAMES.TESTNET,
[FUTURENET_NETWORK_DETAILS.networkPassphrase]: NETWORK_NAMES.FUTURENET,
};

// The token-prices endpoint only supports pubnet and testnet. This map is the
// single source of truth for which passphrases resolve to a price-supported
// network; anything not listed here is skipped by getTokenPrices.
export const PASSPHRASE_TO_PRICE_NETWORK: Record<string, NETWORKS> = {
[Networks.PUBLIC]: NETWORKS.PUBLIC,
[Networks.TESTNET]: NETWORKS.TESTNET,
};
2 changes: 1 addition & 1 deletion extension/e2e-tests/helpers/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ export const stubTokenDetails = async (page: Page | BrowserContext) => {
};

export const stubTokenPrices = async (page: Page | BrowserContext) => {
await page.route("**/token-prices", async (route) => {
await page.route("**/token-prices*", async (route) => {
const request = route.request();

let tokenIds = [] as string[];
Expand Down
2 changes: 1 addition & 1 deletion extension/e2e-tests/sendPayment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function visibleTokenList(page: Page) {
}

async function stubSendTokenPrices(context: BrowserContext) {
await context.route("**/token-prices", async (route) => {
await context.route("**/token-prices*", async (route) => {
const request = route.request();
let tokenIds = [] as string[];

Expand Down
Loading
Loading