Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c7a43ca
refactor(assets): add shared asset-identity predicates
CassioMG Sep 5, 2026
401c875
refactor(send): build the send operation from the asset's type
CassioMG Sep 5, 2026
99dbe08
refactor(balances): enter the native SAC branch by balance type
CassioMG Sep 5, 2026
4d2d008
refactor(sign): decide fee sufficiency from the native balance
CassioMG Sep 5, 2026
cdaa4f2
refactor(icons): resolve the native icon from code and issuer together
CassioMG Sep 5, 2026
c3d7e1b
refactor(history): resolve row identity from the record and the contract
CassioMG Sep 5, 2026
e085c56
refactor(history): match operations to assets by the full pair
CassioMG Sep 5, 2026
28f212c
test(history): fix Soroban transfer icon fixture using the native SAC…
CassioMG Sep 5, 2026
73f0434
test(history): cover the source_asset_* arm of operationMatchesAssetKey
CassioMG Sep 5, 2026
fb75a75
refactor(soroban): derive the native contract address per network
CassioMG Sep 5, 2026
badc7d7
refactor(add-token): build the native search row without an issuer
CassioMG Sep 5, 2026
35950b0
refactor(asset-lists): require both sides of an identity to be present
CassioMG Sep 5, 2026
f913b40
chore(lint): add an asset-identity comparison rule
CassioMG Sep 5, 2026
645e99f
fix(lint): type the asset-identity rule object as a RuleModule
CassioMG Sep 5, 2026
0e3b650
refactor(assets): route shared native checks through the predicates
CassioMG Sep 5, 2026
f4d0302
refactor(assets): route helper native checks through the predicates
CassioMG Sep 5, 2026
ca23309
fix(assets): decide native-code asset identity by the (code, issuer) …
CassioMG Sep 5, 2026
63fc8d5
refactor(assets): route account and list component checks through the…
CassioMG Sep 5, 2026
f29fb03
refactor(assets): route send and swap component checks through the pr…
CassioMG Sep 5, 2026
dab8e6b
refactor(assets): route view native checks through the predicates
CassioMG Sep 5, 2026
217be89
fix(assets): anchor the native-code identity check on the balance's type
CassioMG Sep 5, 2026
f25dfa9
chore(lint): enforce the asset-identity comparison rule
CassioMG Sep 5, 2026
12f06d7
docs(assets): document the asset identity convention
CassioMG Sep 5, 2026
5159097
fix(assets): reuse TokenList's type-anchored native check for its ico…
CassioMG Sep 5, 2026
c305208
docs(assets): correct the ESLint rule's enforcement scope and limits
CassioMG Sep 5, 2026
770aa55
fix(add-token): give the native asset's search row its contract id
CassioMG Sep 5, 2026
a49094d
fix(lint): report comparisons against the SDK's native code
CassioMG Sep 5, 2026
5e8526b
fix(add-token): build the native row from the network's native details
CassioMG Sep 5, 2026
095a2af
fix(icons): compare every render-affecting prop in AssetIcon's memo
CassioMG Sep 5, 2026
feea423
fix(assets): parse canonical identifiers at the last colon and valida…
CassioMG Sep 9, 2026
b81e332
fix(types): exclude the native member from the classic token type
CassioMG Sep 9, 2026
66e102b
test(assets): describe the pinned native addresses and derived-addres…
CassioMG Sep 9, 2026
10c56da
Merge branch 'master' into chore/consolidate-asset-identity-checks
CassioMG Sep 9, 2026
b27464b
docs(asset-lists): say what the membership test actually compares
CassioMG Sep 9, 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
3 changes: 2 additions & 1 deletion @shared/api/helpers/addBlockaidScanResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { captureException } from "@sentry/browser";

import { INDEXER_URL } from "@shared/constants/mercury";
import { NETWORKS, NetworkDetails } from "@shared/constants/stellar";
import { isNativeAssetId } from "@shared/helpers/assetIdentity";
import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar";
import { BlockAidScanAssetResult } from "../types";
import { AccountBalancesInterface, BalanceMap } from "../types/backend-api";
Expand Down Expand Up @@ -37,7 +38,7 @@ export const addBlockaidScanResults = async (
// swap the separator: `CODE-ISSUER` (same convention as v1 and the
// standalone path).
const scannableIds = keys.filter(
(key) => key !== "native" && !key.includes(":lp"),
(key) => !isNativeAssetId(key) && !key.includes(":lp"),
);

if (
Expand Down
3 changes: 2 additions & 1 deletion @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getSdk,
isCustomNetwork,
makeDisplayableBalances,
splitCanonical,
xlmToStroop,
} from "@shared/helpers/stellar";
import {
Expand Down Expand Up @@ -1385,7 +1386,7 @@ export const getAssetIcons = async ({
// Unheld extras run the same cache -> token lists -> issuer-toml chain as
// the held balances above (toml via the shared domainsToFetch batch below).
for (const canonical of additionalAssetIds || []) {
const [code, key] = canonical.split(":");
const { code, issuer: key } = splitCanonical(canonical);
if (!key || canonical in assetIcons) {
// native (no issuer segment) or already covered by a held balance
continue;
Expand Down
2 changes: 1 addition & 1 deletion @shared/api/types/account-balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface NativeAsset {

export interface ClassicAsset {
token: {
type: Omit<SdkAssetType, "native">;
type: Exclude<SdkAssetType, "native">;
code: string;
issuer: { key: string };
};
Expand Down
13 changes: 11 additions & 2 deletions @shared/api/types/backend-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AssetBalance, NativeBalance, TokenBalance } from "./types";
import { AssetType as SdkAssetType } from "stellar-sdk";

export interface BalanceMap {
[key: string]: AssetBalance | NativeBalance | TokenBalance;
Expand Down Expand Up @@ -79,7 +80,11 @@ export interface V2NativeBalance extends V2BalanceBase {
export interface V2ClassicBalance extends V2BalanceBase {
token_type: "CLASSIC";
// `type` is the trustline's asset type verbatim (e.g. credit_alphanum4).
token: { type: string; code: string; issuer: V2TokenIssuer };
token: {
type: Exclude<SdkAssetType, "native">;
code: string;
issuer: V2TokenIssuer;
};
code?: string;
issuer?: string;
type: string;
Expand All @@ -94,7 +99,11 @@ export interface V2ClassicBalance extends V2BalanceBase {
export interface V2SacBalance extends V2BalanceBase {
token_type: "SAC";
// `type` is derived server-side from the code length (credit_alphanum4/12).
token: { type: string; code: string; issuer: V2TokenIssuer };
token: {
type: Exclude<SdkAssetType, "native">;
code: string;
issuer: V2TokenIssuer;
};
code: string;
issuer: string;
decimals: number;
Expand Down
160 changes: 160 additions & 0 deletions @shared/helpers/__tests__/assetIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import BigNumber from "bignumber.js";
import { Asset, Networks } from "stellar-sdk";

import { AssetType } from "@shared/api/types/account-balance";
import {
getAssetFromCanonical,
getCanonicalFromAsset,
} from "@shared/helpers/stellar";
import {
getNativeContractId,
isNativeAsset,
isNativeAssetId,
isNativeAssetPair,
isNativeBalance,
isNativeContract,
} from "@shared/helpers/assetIdentity";

// A classic asset that uses the native asset's display code but carries its
// own issuer. It is a different asset from the native lumen, and every
// predicate has to say so.
const XLM_CODED_ISSUER =
"GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";

// The published native SAC addresses, pinned rather than re-derived, so these
// tests assert that deriving from the passphrase reproduces the known-good
// values rather than just agreeing with themselves.
const NATIVE_SAC_PUBLIC =
"CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
const NATIVE_SAC_TESTNET =
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";

const nativeBalance = {
token: { type: "native", code: "XLM" },
total: new BigNumber("100"),
available: new BigNumber("100"),
buyingLiabilities: "0",
sellingLiabilities: "0",
minimumBalance: "1",
blockaidData: {},
} as unknown as AssetType;

const xlmCodedClassicBalance = {
token: {
type: "credit_alphanum4",
code: "XLM",
issuer: { key: XLM_CODED_ISSUER },
},
total: new BigNumber("100"),
available: new BigNumber("100"),
buyingLiabilities: "0",
sellingLiabilities: "0",
blockaidData: {},
} as unknown as AssetType;

describe("isNativeAssetId", () => {
it("accepts the native identifier", () => {
expect(isNativeAssetId("native")).toBe(true);
});

it("rejects the display code, which is not an identifier", () => {
expect(isNativeAssetId("XLM")).toBe(false);
});

it("rejects a canonical pair that uses the native code", () => {
expect(isNativeAssetId(`XLM:${XLM_CODED_ISSUER}`)).toBe(false);
});

it("rejects an absent id", () => {
expect(isNativeAssetId(undefined)).toBe(false);
expect(isNativeAssetId(null)).toBe(false);
});
});

describe("isNativeAssetPair", () => {
it("accepts the native code with no issuer", () => {
expect(isNativeAssetPair("XLM", undefined)).toBe(true);
expect(isNativeAssetPair("XLM", "")).toBe(true);
});

it("rejects the native code paired with an issuer", () => {
expect(isNativeAssetPair("XLM", XLM_CODED_ISSUER)).toBe(false);
});

it("rejects a different code with no issuer", () => {
expect(isNativeAssetPair("USDC", undefined)).toBe(false);
});
});

describe("isNativeBalance", () => {
it("accepts a native-typed balance", () => {
expect(isNativeBalance(nativeBalance)).toBe(true);
});

it("rejects a classic balance that uses the native code", () => {
expect(isNativeBalance(xlmCodedClassicBalance)).toBe(false);
});
});

describe("isNativeAsset", () => {
it("accepts the SDK native asset", () => {
expect(isNativeAsset(Asset.native())).toBe(true);
});

it("rejects a classic asset that uses the native code", () => {
expect(isNativeAsset(new Asset("XLM", XLM_CODED_ISSUER))).toBe(false);
});

it("rejects the plain shape used for Soroban issuers", () => {
expect(
isNativeAsset({
code: "XLM",
issuer: "CCV3NAKLIBBNSJNNTV2AZVRX6VODUDWK4TVYILE5MW6R45SSQJS5VCAM",
}),
).toBe(false);
});

it("rejects a contract token whose symbol contains a colon", () => {
const contract = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
const canonical = getCanonicalFromAsset("XLM:", contract);
expect(isNativeAsset(getAssetFromCanonical(canonical))).toBe(false);
});
});

describe("getNativeContractId", () => {
it("reproduces the published mainnet native SAC address", () => {
expect(getNativeContractId(Networks.PUBLIC)).toBe(NATIVE_SAC_PUBLIC);
});

it("reproduces the published testnet native SAC address", () => {
expect(getNativeContractId(Networks.TESTNET)).toBe(NATIVE_SAC_TESTNET);
});

it("derives a distinct address on FUTURENET", () => {
const futurenet = getNativeContractId(Networks.FUTURENET);
expect(futurenet).toMatch(/^C[A-Z2-7]{55}$/);
expect(futurenet).not.toBe(NATIVE_SAC_PUBLIC);
});
});

describe("isNativeContract", () => {
it("accepts the native SAC for its own network", () => {
expect(isNativeContract(NATIVE_SAC_PUBLIC, Networks.PUBLIC)).toBe(true);
});

it("rejects the native SAC of a different network", () => {
expect(isNativeContract(NATIVE_SAC_TESTNET, Networks.PUBLIC)).toBe(false);
});

it("rejects the wrapped contract of a classic asset using the native code", () => {
const wrapped = new Asset("XLM", XLM_CODED_ISSUER).contractId(
Networks.PUBLIC,
);
expect(isNativeContract(wrapped, Networks.PUBLIC)).toBe(false);
});

it("rejects an empty contract id, which an absent lookup can produce", () => {
expect(isNativeContract("", Networks.PUBLIC)).toBe(false);
expect(isNativeContract(undefined, Networks.PUBLIC)).toBe(false);
});
});
70 changes: 70 additions & 0 deletions @shared/helpers/__tests__/canonical.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Asset } from "stellar-sdk";

import {
LP_ISSUER_KEY,
getAssetFromCanonical,
getCanonicalFromAsset,
splitCanonical,
} from "@shared/helpers/stellar";

const ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
const CONTRACT = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
const POOL_ID =
"dd7b1ab831c273310ddbec6f97870aa83c2fbd78ce22aded37ecbf4f3380fac7";

describe("splitCanonical", () => {
it("splits a classic canonical into code and issuer", () => {
expect(splitCanonical(`USDC:${ISSUER}`)).toEqual({
code: "USDC",
issuer: ISSUER,
});
});

it("splits on the last colon, so a symbol containing a colon keeps it", () => {
expect(splitCanonical(`MY:TOKEN:${CONTRACT}`)).toEqual({
code: "MY:TOKEN",
issuer: CONTRACT,
});
});

it("returns an empty issuer when there is no separator", () => {
expect(splitCanonical("native")).toEqual({ code: "native", issuer: "" });
});
});

describe("getAssetFromCanonical", () => {
it("returns the SDK asset for a classic canonical", () => {
const asset = getAssetFromCanonical(`USDC:${ISSUER}`) as Asset;
expect(asset.getCode()).toBe("USDC");
expect(asset.getIssuer()).toBe(ISSUER);
});

it("returns the plain shape for a contract token", () => {
expect(getAssetFromCanonical(`TKN:${CONTRACT}`)).toEqual({
code: "TKN",
issuer: CONTRACT,
});
});

it("keeps a colon-bearing symbol whole and round-trips it", () => {
const canonical = getCanonicalFromAsset("XLM:", CONTRACT);
expect(getAssetFromCanonical(canonical)).toEqual({
code: "XLM:",
issuer: CONTRACT,
});
});

it("accepts the liquidity-pool sentinel issuer", () => {
expect(getAssetFromCanonical(`${POOL_ID}:${LP_ISSUER_KEY}`)).toEqual({
code: POOL_ID,
issuer: LP_ISSUER_KEY,
});
});

it("rejects an issuer that is neither a G address, a C address nor the pool sentinel", () => {
expect(() => getAssetFromCanonical("XLM:")).toThrow(
/invalid asset canonical id/,
);
expect(() => getAssetFromCanonical("USDC:not-an-issuer")).toThrow();
});
});
81 changes: 81 additions & 0 deletions @shared/helpers/assetIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Asset } from "stellar-sdk";

import { AssetType, NativeAsset } from "@shared/api/types/account-balance";

/**
* Shared asset-identity predicates.
*
* An asset's identity is the pair (code, issuer) — or, for a contract token,
* its contract id. Asset codes are not unique, so a code alone establishes
* neither that two assets are the same asset nor that an asset is the native
* lumen. Nativeness is a property of an asset's type, or in contract space of
* the contract id. Every native check in the extension goes through one of the
* predicates below.
*/

/**
* The identifier the codebase uses for the native lumen. It is simultaneously
* the canonical asset id, a Horizon record's `asset_type`, and a balance's
* `token.type`.
*/
const NATIVE_ASSET_ID = "native";

/** The native lumen's display code. Not unique — other assets may use it. */
const NATIVE_ASSET_CODE = "XLM";

/**
* True if `id` is the native identifier. Use it for canonical asset ids,
* Horizon `asset_type` values, and balance `token.type` values.
*/
export const isNativeAssetId = (id: string | undefined | null): boolean =>
id === NATIVE_ASSET_ID;

/**
* Native test for a raw code and issuer. Use it only where neither a token
* type nor a contract id is available — the native asset carries the native
* code and no issuer, so both halves are required.
*/
export const isNativeAssetPair = (
code: string | undefined | null,
issuer: string | undefined | null,
): boolean => code === NATIVE_ASSET_CODE && !issuer;

/** True only for a balance whose token declares the native type. */
export const isNativeBalance = (balance: AssetType): balance is NativeAsset =>
"token" in balance &&
"type" in balance.token &&
isNativeAssetId(balance.token.type);

/**
* True only for the native asset.
*
* `getAssetFromCanonical` returns an SDK `Asset` for classic assets and a plain
* `{ code, issuer }` for contract tokens and liquidity-pool shares, so both
* shapes arrive here. The SDK's own `isNative()` is authoritative when present.
* The plain shape's issuer is a validated `C…` address or the pool sentinel —
* never empty, which the parser rejects — so the pair test correctly rejects
* it.
*
* Narrowing on the method rather than `instanceof` keeps this correct across
* the `stellar-sdk` / `stellar-sdk-next` dual-package split.
*/
export const isNativeAsset = (
asset: Asset | { code: string; issuer?: string },
): boolean =>
typeof (asset as Asset).isNative === "function"
? (asset as Asset).isNative()
: isNativeAssetPair(asset.code, asset.issuer);

/**
* The native lumen's Stellar Asset Contract id, derived from the network
* passphrase so it is correct by construction on every network.
*/
export const getNativeContractId = (networkPassphrase: string): string =>
Asset.native().contractId(networkPassphrase);

/** True only when `contractId` is the native SAC for the given network. */
export const isNativeContract = (
contractId: string | undefined | null,
networkPassphrase: string,
): boolean =>
!!contractId && contractId === getNativeContractId(networkPassphrase);
Loading
Loading