Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
00e16ab
feat(swap): read Soroban contract tokens at their own decimals
piyalbasu Nov 9, 2026
9a1c6c1
fix(swap): no trustline or reserve flow for Soroban contract tokens
piyalbasu Nov 9, 2026
14d9ba4
feat(swap): offer Soroban contract tokens in the token picker
piyalbasu Nov 9, 2026
fe68b49
feat(swap): subtitle Soroban tokens with their shortened contract ID
piyalbasu Nov 9, 2026
98dbc66
feat(swap): picker icons from the asset lists, then stellar.toml, cac…
piyalbasu Nov 9, 2026
067c104
test(swap): a Soroban token no source routes shows no quote
piyalbasu Nov 9, 2026
c26139c
style(e2e): prettier
piyalbasu Nov 9, 2026
0d093eb
fix(swap): read an unheld contract token's decimals on chain, or refuse
piyalbasu Nov 9, 2026
4907b5a
refactor(swap): one contract-ID check, isContractId
piyalbasu Nov 9, 2026
50dfd5d
fix(swap): apply the classic send cap to classic assets only
piyalbasu Nov 9, 2026
805e704
revert(swap): don't write a picked token's icon to the shared cache
piyalbasu Nov 9, 2026
14be178
feat(swap): off mainnet, leave unheld Soroban tokens out of discovery
piyalbasu Nov 9, 2026
1f3685d
feat(swap): offer Swap on a Soroban token's details screen
piyalbasu Nov 9, 2026
97ae1c5
fix(swap): a SAC held by contract ID still needs its trustline
piyalbasu Nov 9, 2026
e295b03
fix(swap): reset isToken when swap-for-reserve replaces the source
piyalbasu Nov 9, 2026
5c084cb
fix(swap): name a contract token with no code by its contract ID
piyalbasu Nov 9, 2026
dc38ff3
Merge feat/swap-backend-tx (with origin/master) into feat/swap-soroba…
piyalbasu Nov 9, 2026
680585e
Merge feat/swap-backend-tx into feat/swap-soroban-tokens
piyalbasu Nov 10, 2026
3eb3673
Merge feat/swap-backend-tx into feat/swap-soroban-tokens
piyalbasu Nov 11, 2026
dd04d50
Merge branch 'feat/swap-backend-tx' into feat/swap-soroban-tokens
piyalbasu Nov 11, 2026
a569494
Merge branch 'feat/swap-backend-tx' into feat/swap-soroban-tokens
piyalbasu Nov 11, 2026
b7e80b5
Merge branch 'feat/swap-backend-tx' into feat/swap-soroban-tokens
piyalbasu Nov 11, 2026
edab953
fix(swap): a contract token is one identity, its contract ID
piyalbasu Nov 12, 2026
945981d
fix(swap): read an unheld contract token's decimals when it is picked
piyalbasu Nov 12, 2026
7ad69cd
fix(swap): look a pasted contract ID up through the backend
piyalbasu Nov 12, 2026
44e8fee
fix(swap): list a held contract token searched by another code
piyalbasu Nov 12, 2026
0912ca0
fix(swap): the direction toggle sets isToken from the token it sells
piyalbasu Nov 12, 2026
d7bca37
fix(swap): a held contract picked under another code is the balance
piyalbasu Nov 12, 2026
c9954b5
fix(swap): the destination pick leaves isToken to the source
piyalbasu Nov 12, 2026
ce84bdb
fix(swap): a plain-text 400 from token-details is "not a token"
piyalbasu Nov 12, 2026
f98bf5c
fix(swap): the pick-time decimals read uses the active account's key
piyalbasu Nov 12, 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
22 changes: 22 additions & 0 deletions @shared/api/__tests__/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,4 +805,26 @@ describe("internalApi", () => {
});
});
});

describe("getTokenDetails", () => {
afterEach(() => jest.restoreAllMocks());

it("treats a plain-text 400 as not a token, even when asked to throw", async () => {
jest.spyOn(global, "fetch").mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.reject(new SyntaxError("Unexpected token S")),
} as unknown as Response);

await expect(
internalApi.getTokenDetails({
contractId:
"CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV",
publicKey: "G123",
networkDetails: MAINNET_NETWORK_DETAILS,
throwOnFailure: true,
}),
).resolves.toBeNull();
});
});
});
41 changes: 41 additions & 0 deletions @shared/api/helpers/__tests__/getIconFromTokenList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { findTokenListIcon } from "../getIconFromTokenList";

const ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
const CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV";

const list = (name: string, assets: Record<string, string>[]) =>
({ name, assets }) as any;

describe("findTokenListIcon", () => {
it("takes the first list that carries the asset", () => {
expect(
findTokenListIcon({
issuerId: ISSUER,
code: "USDC",
assetsListsData: [
list("first", [{ code: "USDC", issuer: ISSUER, icon: "first.png" }]),
list("second", [
{ code: "USDC", issuer: ISSUER, icon: "second.png" },
]),
],
}),
).toEqual({ icon: "first.png", canonicalAsset: `USDC:${ISSUER}` });
});

it("matches a contract token by its contract ID", () => {
expect(
findTokenListIcon({
contractId: CONTRACT,
code: "TKN",
assetsListsData: [list("l", [{ contract: CONTRACT, icon: "tkn.png" }])],
}),
).toEqual({ icon: "tkn.png", canonicalAsset: `TKN:${CONTRACT}` });
});

it("finds nothing for an unlisted asset", () => {
expect(
findTokenListIcon({ issuerId: ISSUER, code: "USDC", assetsListsData: [] })
.icon,
).toBeUndefined();
});
});
89 changes: 50 additions & 39 deletions @shared/api/helpers/getIconFromTokenList.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {
AssetListReponseItem,
AssetListResponse,
} from "@shared/constants/soroban/asset-list";
import { AssetListResponse } from "@shared/constants/soroban/asset-list";
import { getCanonicalFromAsset } from "@shared/helpers/stellar";

import { sendMessageToBackground } from "./extensionMessaging";
import { SERVICE_TYPES } from "../../constants/services";

export const getIconFromTokenLists = async ({
/**
* The icon of the first list (in the user's priority order) that carries the
* asset, matched by contract ID or by code and issuer.
*/
export const findTokenListIcon = ({
issuerId,
contractId,
code,
Expand All @@ -18,51 +19,61 @@ export const getIconFromTokenLists = async ({
code: string;
assetsListsData: AssetListResponse[];
}) => {
let verifiedToken = {} as AssetListReponseItem;
let canonicalAsset = undefined as string | undefined;
// The lists arrive in the user's configured order, which is a priority order:
// the first list carrying the asset wins. Without the labeled break below the
// outer loop runs to completion and the LAST matching list silently overwrites
// earlier, higher-priority entries.
listLoop: for (const data of assetsListsData) {
const list = data.assets;
if (list) {
for (const record of list) {
if (contractId) {
const regex = new RegExp(contractId, "i");
if (record.contract && record.contract.match(regex) && record.icon) {
verifiedToken = record;
canonicalAsset = getCanonicalFromAsset(code, contractId);
break listLoop;
}
for (const data of assetsListsData) {
for (const record of data.assets ?? []) {
if (contractId) {
const regex = new RegExp(contractId, "i");
if (record.contract && record.contract.match(regex) && record.icon) {
return {
icon: record.icon,
canonicalAsset: getCanonicalFromAsset(code, contractId),
};
}
}

if (
issuerId &&
record.issuer &&
record.issuer === issuerId &&
record.code === code &&
record.icon
) {
verifiedToken = record;
canonicalAsset = getCanonicalFromAsset(code, issuerId);
break listLoop;
}
if (
issuerId &&
record.issuer &&
record.issuer === issuerId &&
record.code === code &&
record.icon
) {
return {
icon: record.icon,
canonicalAsset: getCanonicalFromAsset(code, issuerId),
};
}
}
}
return { icon: undefined, canonicalAsset: undefined };
};

export const getIconFromTokenLists = async ({
issuerId,
contractId,
code,
assetsListsData,
}: {
issuerId?: string;
contractId?: string;
code: string;
assetsListsData: AssetListResponse[];
}) => {
const found = findTokenListIcon({
issuerId,
contractId,
code,
assetsListsData,
});

if (verifiedToken?.icon) {
if (found.icon) {
await sendMessageToBackground({
activePublicKey: null,
assetCanonical: `${code}:${contractId || issuerId}`,
iconUrl: verifiedToken?.icon,
iconUrl: found.icon,
type: SERVICE_TYPES.CACHE_ASSET_ICON,
});
}

return {
icon: verifiedToken?.icon,
canonicalAsset,
};
return found;
};
11 changes: 11 additions & 0 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1474,12 +1474,15 @@ export const getTokenDetails = async ({
networkDetails,
shouldFetchBalance,
signal,
throwOnFailure = false,
}: {
contractId: string;
publicKey: string;
networkDetails: NetworkDetails;
shouldFetchBalance?: boolean;
signal?: AbortSignal;
/** Rethrow a failed request, so it can be told apart from "not a token". */
throwOnFailure?: boolean;
}): Promise<{
name: string;
decimals: number;
Expand Down Expand Up @@ -1537,6 +1540,11 @@ export const getTokenDetails = async ({
}${shouldFetchBalance ? "&should_fetch_balance=true" : ""}`,
{ signal },
);
// Not a token contract: an answer, not a failure. Checked before the
// body is parsed, since a 400 can be plain text.
if (response.status === 400) {
return null;
}
const data = await response.json();
if (!response.ok) {
throw new Error(data);
Expand All @@ -1553,6 +1561,9 @@ export const getTokenDetails = async ({
error,
)} - ${contractId} - ${networkDetails.network}`,
);
if (throwOnFailure) {
throw error;
}
return null;
}
};
Expand Down
2 changes: 2 additions & 0 deletions @shared/api/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface TrendingAsset {
domain: string | null;
icon?: string;
volume7d: number;
/** Soroban contract tokens only. */
decimals?: number;
}

export interface AllowList {
Expand Down
28 changes: 10 additions & 18 deletions extension/e2e-tests/swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
* - trustline-info-sheet TrustlineInfoSheet.tsx:16
* - XlmReserveSheet XlmReserveSheet/index.tsx:27
* - swap-picker-fallback-notice SwapPickerSections/index.tsx:108
* - swap-picker-empty-soroban SwapPickerSections/index.tsx:123
* - blockaid-malicious-label WarningMessages/index.tsx:768,933
*
* testids NOT yet in source (design doc used wrong names; real counterparts above):
Expand All @@ -55,7 +54,7 @@ import {
TESTNET_TRENDING_ASSETS_ROUTE,
TRENDING_ASSETS_ROUTE,
} from "./helpers/stubs";
// Soroban contract address — searching for this should produce the Soroban empty state.
// A Soroban-only token's contract address.
const SOROBAN_CONTRACT_ADDRESS =
"CAZXRTOKNUQ2JQQF3NCRU7GYMDJNZ2NMQN6IGN4FCT5DWPODMPVEXSND";

Expand Down Expand Up @@ -435,24 +434,23 @@ test("flagged destination surfaces blockaid malicious warning at review", async
});

// ---------------------------------------------------------------------------
// 5. Search: Soroban contract address → Soroban empty state
// Entering a contract address in the "Swap to" search should surface the
// Soroban-unsupported empty state copy.
// 5. Search: Soroban contract address → the contract token's row
// ---------------------------------------------------------------------------
test("search with Soroban contract address shows Soroban empty state", async ({
test("search with a Soroban contract address lists the contract token", async ({
page,
extensionId,
context,
}) => {
test.slow();

const stubOverrides = async () => {
// Return empty results — the Soroban detection happens in the picker via
// hadSorobanMatches logic in SwapPickerSections based on the search term
// being a contract-shaped address; no search result records needed.
await context.unroute(ASSET_SEARCH_ROUTE);
await context.route(ASSET_SEARCH_ROUTE, (route) =>
route.fulfill(assetSearchResponse([])),
route.fulfill(
assetSearchResponse([
{ asset: SOROBAN_CONTRACT_ADDRESS, code: "TKN", decimals: 18 },
]),
),
);
await context.route(TRENDING_ASSETS_ROUTE, (route) =>
route.fulfill(assetSearchResponse([])),
Expand All @@ -466,18 +464,12 @@ test("search with Soroban contract address shows Soroban empty state", async ({
await expect(page.getByTestId("swap-sell-card")).toBeVisible();

await openSwapToPicker(page);
// Type a Soroban contract address — SwapPickerSections.hadSorobanMatches
// triggers when the search term looks like a contract ID (56-char Stellar G/C address)
await page.getByTestId("swap-from-search").fill(SOROBAN_CONTRACT_ADDRESS);

// The Soroban empty state message (swap-picker-empty-soroban)
// Text: "Soroban contract tokens aren't supported for swaps yet."
await expect(page.getByTestId("swap-picker-empty-soroban")).toBeVisible({
await expect(page.getByTestId("SwapTokenRow-TKN")).toBeVisible({
timeout: 15000,
});
await expect(
page.getByText(/Soroban contract tokens aren't supported/),
).toBeVisible();
await expect(page.getByTestId("SwapTokenRow-TKN")).toContainText("CAZX…XSND");
});

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ interface ReviewTxProps {
destinationTokenDetails?: {
tokenCode: string;
requiresTrustline: boolean;
decimals: number;
decimals?: number;
issuer?: string;
// Blockaid verdict captured when the destination token was picked; folded
// into the review security gate alongside the transaction scan.
Expand Down
4 changes: 2 additions & 2 deletions extension/src/popup/components/__tests__/AssetDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1062,7 +1062,7 @@ describe("AssetDetail", () => {
expect(screen.getByTestId("asset-detail-swap-button")).toBeVisible();
});

it("should not show swap button for Soroban asset", async () => {
it("shows the swap button for a Soroban asset", async () => {
const contractId =
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4";
const props = {
Expand Down Expand Up @@ -1104,7 +1104,7 @@ describe("AssetDetail", () => {
);

await waitFor(() => screen.getByTestId("AssetDetail"));
expect(screen.queryByTestId("asset-detail-swap-button")).toBeNull();
expect(screen.getByTestId("asset-detail-swap-button")).toBeVisible();
expect(screen.getByTestId("asset-detail-send-button")).toBeVisible();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ export const AssetDetail = ({
selectedBalance.contractId,
networkDetails.networkPassphrase,
);
const isShowingSwap = !isSorobanAsset && !isLpShare;
const isShowingSwap = !isLpShare;
const isShowingSend = hasBalance;

// See the `AssetDetail__price` div below for why this falls back to spot.
Expand Down
6 changes: 4 additions & 2 deletions extension/src/popup/components/amount/AmountCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isNativeAssetPair } from "@shared/helpers/assetIdentity";
import { SecurityLevel } from "popup/constants/blockaid";
import { InputType } from "helpers/transaction";
import { formatAmountPreserveCursor } from "popup/helpers/formatters";
import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban";
import { useRunAfterUpdate } from "popup/helpers/useRunAfterUpdate";

import "./styles.scss";
Expand Down Expand Up @@ -49,7 +50,8 @@ export interface AmountCardProps {
* (e.g. the swap CTA disables itself while the sell input is focused). */
onInputFocus?: () => void;
onInputBlur?: () => void;
cryptoDecimals: number;
/** Input precision; a read-only card never uses it. */
cryptoDecimals?: number;
onAmountChange: (next: { amount: string; newCursor: number }) => void;
onAmountUsdChange: (next: { amount: string; newCursor: number }) => void;
onToggleInputType: () => void;
Expand Down Expand Up @@ -80,7 +82,7 @@ export const AmountCard = ({
amountInputRef,
onInputFocus,
onInputBlur,
cryptoDecimals,
cryptoDecimals = CLASSIC_ASSET_DECIMALS,
onAmountChange,
onAmountUsdChange,
onToggleInputType,
Expand Down
Loading
Loading