Skip to content
Merged
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
110 changes: 110 additions & 0 deletions __tests__/hooks/useTokenLookup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,46 @@ jest.mock("services/stellarExpert", () => ({
},
});
}
if (searchTerm === "dejaaa") {
return Promise.resolve({
_embedded: {
records: [
{
asset: "CC64WBDGS6QQP22QTTIACYIXT3WF7BBQEYOQPLTP7GTKYY7PZ74QYGSL",
domain: "centrifuge.io",
code: "deJAAA",
token_name: "deJAAA",
decimals: 18,
tomlInfo: {
code: "deJAAA",
issuer:
"CBSZNRNQIFHKHBHPZEHJFWGTJMPLYN4NJYGTLEJBHUVQTTOQ2WSQ6OMV",
image:
"https://stellar.myfilebase.com/ipfs/QmXu7RUtm2zhGDhk1ScDSWH587xccMm8ewprrFr4rYX6F3",
name: "deJAAA",
decimals: 18,
},
},
{
asset:
"DEJAAA-GASIX3XBHMFJGHOMC2FEELMO2E6JYE5LL2V2QBW3GX23GJI2EHWM4R5E-2",
},
],
},
});
}
if (searchTerm === "contract-only") {
return Promise.resolve({
_embedded: {
records: [
{
asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
domain: "example.com",
},
],
},
});
}
if (searchTerm === "error") {
return Promise.reject(new Error("Search failed"));
}
Expand Down Expand Up @@ -294,6 +334,76 @@ describe("useTokenLookup", () => {
expect(tokens).not.toContain("FOO");
});

it("should parse contract tokens from stellar.expert search results", async () => {
const { result } = renderHook(() =>
useTokenLookup({
network: mockNetwork,
publicKey: mockPublicKey,
balanceItems: mockBalanceItems,
}),
);

await act(async () => {
result.current.handleSearch("dejaaa");
await flushPromises();
});

expect(result.current.status).toBe(HookStatus.SUCCESS);
expect(result.current.searchResults).toHaveLength(2);

// First result: contract token — should use top-level `code`, not the raw contract ID
const contractToken = result.current.searchResults[0];
expect(contractToken.tokenCode).toBe("deJAAA");
expect(contractToken.issuer).toBe(
"CC64WBDGS6QQP22QTTIACYIXT3WF7BBQEYOQPLTP7GTKYY7PZ74QYGSL",
);
expect(contractToken.domain).toBe("centrifuge.io");
expect(contractToken.tokenType).toBe(TokenTypeWithCustomToken.CUSTOM_TOKEN);
expect(contractToken.decimals).toBe(18);
expect(contractToken.name).toBe("deJAAA");
expect(contractToken.iconUrl).toBe(
"https://stellar.myfilebase.com/ipfs/QmXu7RUtm2zhGDhk1ScDSWH587xccMm8ewprrFr4rYX6F3",
);

// Second result: classic asset — should be parsed with split("-")
const classicToken = result.current.searchResults[1];
expect(classicToken.tokenCode).toBe("DEJAAA");
expect(classicToken.issuer).toBe(
"GASIX3XBHMFJGHOMC2FEELMO2E6JYE5LL2V2QBW3GX23GJI2EHWM4R5E",
);
});

it("should fall back to contract ID as tokenCode when metadata is missing", async () => {
const { result } = renderHook(() =>
useTokenLookup({
network: mockNetwork,
publicKey: mockPublicKey,
balanceItems: mockBalanceItems,
}),
);

await act(async () => {
result.current.handleSearch("contract-only");
await flushPromises();
});

expect(result.current.status).toBe(HookStatus.SUCCESS);
expect(result.current.searchResults).toHaveLength(1);

const token = result.current.searchResults[0];
// No `code` or `tomlInfo.code` — falls back to contract ID
expect(token.tokenCode).toBe(
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
);
expect(token.issuer).toBe(
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
);
expect(token.domain).toBe("example.com");
expect(token.tokenType).toBe(TokenTypeWithCustomToken.CUSTOM_TOKEN);
expect(token.decimals).toBeUndefined();
expect(token.iconUrl).toBeUndefined();
});

it("should handle contract ID search", async () => {
const { result } = renderHook(() =>
useTokenLookup({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,23 @@ const AddTokenBottomSheetContent: React.FC<AddTokenBottomSheetContentProps> = ({
const listItems = useMemo(() => {
if (!token) return [];

// If issuer is already a contract ID, use it directly
// Otherwise, create an Asset and get its contract ID
const tokenContractId = isContractId(token.issuer)
? token.issuer
: new Asset(token.tokenCode, token.issuer).contractId(networkPassphrase);
// For contract tokens (Soroban), the contract ID is typically stored in
// `issuer`. As a safety fallback, stellar.expert can return contract
// tokens where `asset` is the raw contract ID, so `tokenCode` may hold
// the contract ID instead. Select the contract-bearing field explicitly:
// a non-empty classic issuer must not override a contract ID found in
// `tokenCode`.
const getTokenContractId = () => {
if (isContractId(token.issuer)) return token.issuer;
if (isContractId(token.tokenCode)) return token.tokenCode;

// without the above guard, `new Asset()` would
// throw because contract IDs exceed the 12-char asset code limit
return new Asset(token.tokenCode, token.issuer).contractId(
networkPassphrase,
);
};
const tokenContractId = getTokenContractId();

const items = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ const SwapAmountScreen: React.FC<SwapAmountScreenProps> = ({
duration: 0,
});
}
}, [pathError, sourceAmount, destinationTokenId, activeError]);
}, [pathError, sourceAmount, destinationTokenId]);

const handleSettingsPress = useCallback(() => {
transactionSettingsBottomSheetModalRef.current?.present();
Expand Down
9 changes: 7 additions & 2 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,16 @@ export interface SearchTokenResponse {
};
score: number;
paging_token: number;
tomlInfo: {
tomlInfo?: {
code: string;
image: string;
image?: string;
issuer: string;
decimals?: number;
name?: string;
};
code?: string;
token_name?: string;
decimals?: number;
}[];
};
}
Expand Down
18 changes: 18 additions & 0 deletions src/ducks/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logger } from "config/logger";
import { PricedBalance } from "config/types";
import { useDebugStore } from "ducks/debug";
import { formatBigNumberForDisplay } from "helpers/formatAmount";
import { isContractId } from "helpers/soroban";
import { t } from "i18next";
import { getTokenForPayment } from "services/transactionService";
import { create } from "zustand";
Expand Down Expand Up @@ -184,6 +185,23 @@ export const useSwapStore = create<SwapState>((set) => ({
throw new Error(t("debug.debugMessages.swapPathFailure"));
}

// Custom/Soroban tokens cannot be swapped via Horizon's classic DEX
const isSourceCustom =
"contractId" in sourceBalance ||
("id" in sourceBalance && isContractId(sourceBalance.id ?? ""));
const isDestCustom =
"contractId" in destinationBalance ||
("id" in destinationBalance &&
isContractId(destinationBalance.id ?? ""));

if (isSourceCustom || isDestCustom) {
set({
isLoadingPath: false,
pathError: t("swapScreen.errors.customTokenSwapNotSupported"),
});
return;
}

// For now, we only support classic path payments
// TODO: Add Soroswap support for Testnet in future iteration
const pathResult = await findClassicSwapPath({
Expand Down
75 changes: 62 additions & 13 deletions src/hooks/useTokenLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,21 @@ export const useTokenLookup = ({
return !!matchingBalance;
};

// Format tokens from different sources while preserving original order
// Format tokens from different sources while preserving original order.
//
// Three paths depending on the result origin:
//
// 1. freighter-backend (contract ID search via handleContractLookup)
// → Already a FormattedSearchTokenRecord, just enrich with icon and trustline info.
//
// 2. stellar.expert — Soroban contract token
// → `asset` is a raw contract ID (e.g. "CC64WB...QYGSL").
// Token metadata comes from top-level fields (`code`, `decimals`, `token_name`)
// with `tomlInfo` as fallback.
//
// 3. stellar.expert — Classic asset
// → `asset` is "CODE-ISSUER-TYPE" (e.g. "USDC-GA5ZSE...R5E-1").
// Split by "-" to extract code and issuer.
const formatTokensFromSearchResults = (
rawSearchResults:
| SearchTokenResponse["_embedded"]["records"]
Expand All @@ -144,8 +158,8 @@ export const useTokenLookup = ({
icons: Record<string, Icon> = {},
): FormattedSearchTokenRecord[] =>
rawSearchResults.map((result) => {
// Path 1: Already formatted by freighter-backend (contract ID search)
if ("tokenCode" in result) {
// came from freighter-backend
const tokenIdentifier = getTokenIdentifier({
type: TokenTypeWithCustomToken.CUSTOM_TOKEN,
code: result.tokenCode,
Expand All @@ -165,7 +179,43 @@ export const useTokenLookup = ({
};
}

// came from stellar.expert
// Path 2: stellar.expert — Soroban contract token
// Contract tokens have no "-" in the asset field (it's just the contract ID).
// Metadata (symbol, decimals, name) comes from top-level fields with
// tomlInfo as fallback.
const isContract = isContractId(result.asset);

if (isContract) {
const contractId = result.asset;
const tokenCode = result.code ?? result.tomlInfo?.code ?? contractId;
const tokenIdentifier = getTokenIdentifier({
type: TokenTypeWithCustomToken.CUSTOM_TOKEN,
code: tokenCode,
issuer: {
key: contractId,
},
});
const iconUrl =
icons[tokenIdentifier]?.imageUrl ?? result.tomlInfo?.image;

return {
tokenCode,
domain: result.domain ?? "",
hasTrustline: hasExistingTrustline(
userBalances,
tokenCode,
contractId,
),
iconUrl,
issuer: contractId,
isNative: false,
tokenType: TokenTypeWithCustomToken.CUSTOM_TOKEN,
decimals: result.decimals ?? result.tomlInfo?.decimals,
name: result.token_name ?? result.tomlInfo?.name,
};
}

// Path 3: stellar.expert — Classic asset (asset format: "CODE-ISSUER-TYPE")
const [tokenCode, issuer] = result.asset.split("-");
const tokenIdentifier = getTokenIdentifier({
type: TokenTypeWithCustomToken.CUSTOM_TOKEN,
Expand Down Expand Up @@ -243,31 +293,30 @@ export const useTokenLookup = ({

icons = resJson?.reduce(
(prev, curr) => {
if (!curr.tomlInfo) return prev;

const tokenIdentifier = getTokenIdentifier({
type: TokenTypeWithCustomToken.CREDIT_ALPHANUM4,
code: curr.tomlInfo?.code,
code: curr.tomlInfo.code,
issuer: {
key: curr.tomlInfo?.issuer,
key: curr.tomlInfo.issuer,
},
});

// Apply USDC special case logic inline
let imageUrl = curr.tomlInfo?.image;
let imageUrl = curr.tomlInfo.image;
if (
network === NETWORKS.PUBLIC &&
curr.tomlInfo?.code === USDC_CODE &&
curr.tomlInfo?.issuer === CIRCLE_USDC_ISSUER
curr.tomlInfo.code === USDC_CODE &&
curr.tomlInfo.issuer === CIRCLE_USDC_ISSUER
) {
imageUrl = logos.usdc as unknown as string;
}

const icon = {
imageUrl,
network,
};
if (!imageUrl) return prev;

// eslint-disable-next-line no-param-reassign
prev[tokenIdentifier] = icon;
prev[tokenIdentifier] = { imageUrl, network };
return prev;
},
{} as Record<string, Icon>,
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,8 @@
"insufficientBalance": "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}",
"insufficientXlmForFees": "Insufficient XLM for transaction fees. You need at least {{fee}} XLM to pay the transaction fee.",
"noPathFound": "No path found between these tokens",
"pathFindFailed": "Couldn't fetch swap path. Please try again."
"pathFindFailed": "Couldn't fetch swap path. Please try again.",
"customTokenSwapNotSupported": "Swapping custom tokens is not supported yet"
},
"proceedAnyway": "Proceed anyway"
},
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/pt/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,8 @@
"insufficientBalance": "Saldo insuficiente. Máximo disponível: {{amount}} {{symbol}}",
"insufficientXlmForFees": "XLM insuficiente para taxas de transação. Você precisa de pelo menos {{fee}} XLM para pagar a taxa de transação.",
"noPathFound": "Nenhum caminho encontrado entre estes tokens",
"pathFindFailed": "Não foi possível buscar o caminho de troca. Tente novamente."
"pathFindFailed": "Não foi possível buscar o caminho de troca. Tente novamente.",
"customTokenSwapNotSupported": "Troca de tokens customizados ainda não é suportada"
},
"proceedAnyway": "Prosseguir mesmo assim"
},
Expand Down
Loading