Skip to content

refactor(assets): consolidate asset identity checks - #3000

Merged
CassioMG merged 34 commits into
masterfrom
chore/consolidate-asset-identity-checks
Sep 10, 2026
Merged

refactor(assets): consolidate asset identity checks#3000
CassioMG merged 34 commits into
masterfrom
chore/consolidate-asset-identity-checks

Conversation

@CassioMG

@CassioMG CassioMG commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

TL;DR

This PR consolidates the extension's native-asset identity checks into a small set of shared predicates, each keyed on the authoritative discriminant for its layer: a token's type, the native contract address derived from the network passphrase, or the full canonical identifier.

The flows themselves are unchanged; what changes is how the identity checks are made, and where that logic lives.

It also adds a lint rule that reports a ===/!== with a native-asset sentinel on either side, so new code reaches for an existing predicate instead of re-deriving one; @shared/helpers/assetIdentity.ts is exempt as the module that defines them, and the convention is written up in docs/skills/freighter-best-practices/references/anti-patterns.md.

Closes https://github.com/stellar/wallet-eng-monorepo/issues/61

Size

Most of the diff is tests — the production surface is small (net +200).

Files Lines
Production source 38 +475 / −275 (net +200)
Tests 14 +1131 / −8
Lint rule (plugin + config) 2 +124 / −0
Docs 2 +88 / −1
Total 56 +1818 / −284
Implementation details (for agents)

The shared predicates. @shared/helpers/assetIdentity.ts (new) holds one predicate per identifier layer: isNativeBalance / isNativeAsset (type-based, for a balance object or an SDK Asset), isNativeAssetId (the canonical id, a Horizon asset_type, or a token.type — matches "native" only), getNativeContractId / isNativeContract (contract space, derived via Asset.native().contractId(passphrase)), and isNativeAssetPair(code, issuer) for the raw-strings layer where nothing better is available. isNativeBalance moves here from popup/helpers/balance.ts. Identity for anything other than nativeness — equality, map keys, labels — goes through the existing getCanonicalFromAsset.

Transaction building. useSimulateTxData's getOperation decides its create-account branch from the asset's type via isNativeAsset, and is exported so the operation it builds is pinned on parsed operations.

Balances and signing. getBalanceByKey enters its native-contract branch on the balance's type. The signing screen's fee pre-flight is extracted to hasEnoughXlmForFee in popup/helpers/balance.ts, anchored on the native balance.

Icons, history and display. AssetIcon resolves the native icon from code and issuer together, and its memo comparator compares every render-affecting prop; the callers that build its icon map use the same pair. History icon selection pairs each code with its issuer, the Soroban transfer row decides nativeness from the contract address, and asset-detail operation matching is extracted to operationMatchesAssetKey with separate native and classic arms.

Contract space and add-a-token. getNativeContractDetails derives the native contract address from the passphrase for every network. isAssetSac's native branch, useAssetLookup, useTokenLookup and AddAsset resolve the native contract through isNativeContract; the native search row is built by buildNativeAssetRow, and stellar.expert records are mapped by mapStellarExpertRecord alongside it. getAssetListsForAsset requires both sides of an identity to be present before comparing them.

The lint rule and the convention. config/eslint-plugin-asset-identity/ (new local plugin, wired into eslint.config.js at error level) adds no-asset-code-comparison: it reports a ===/!== with "XLM", "native", the SDK's own native code (Asset.native().code / .getCode()), or the identifier names NATIVE_TOKEN_CODE / HORIZON_NATIVE_ASSET_TYPE on either side. Asset.native().contractId(...) is not reported: a contract-id comparison is the sound check in contract space. Every site the rule flags was migrated in this branch — 62 comparisons across 31 files, each routed to the predicate matching what its operand holds — so it reports zero hits on the tree. The convention is written up in anti-patterns.md §11 with a what-you-hold → which-predicate table, and code-style.md records the rule.

Verification. Full Jest suite green: 1851 passed / 51 skipped across 234 suites. yarn build:extension is clean with the rule live (ESLint runs inside the webpack build for extension/), and the rule was confirmed to fire before the tree was declared clean. Each migrated site has tests covering its predicate's discriminating cases — a genuine native control alongside a classic asset that uses the code XLM with its own issuer — and derived contract ids are pinned against the published PUBLIC and TESTNET addresses. No user-facing strings were added.

Follow-ups / out of scope. Extending the build's lint gate from extension/ to @shared (the docs state the current scope). Issuer rows on the liquidity-pool branch of the trustline approval pane. Worth a smoke test before release: the add-a-token search and icon surfaces, which now resolve the native asset through the shared predicates.

🤖 Generated with Claude Code

CassioMG and others added 26 commits September 4, 2026 20:05
Add @shared/helpers/assetIdentity with one predicate per identifier layer:
token type, canonical identifier, contract id (derived from the network
passphrase), and the raw code/issuer pair. Move isNativeBalance there from
popup/helpers/balance so every nativeness check has one home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getOperation chose the create-account branch by comparing the source asset's
code to the native code. CreateAccountOp carries no asset field, so that branch
is only ever correct for the native asset itself. Gate it on isNativeAsset, so
a classic asset that uses the same code falls through to the payment branch
carrying the asset it actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getBalanceByKey decided which balance to compare against the native contract
address from the balance's code, and returned early from that branch. Gate it
on isNativeBalance so the native SAC resolves the native balance and any other
asset reaches the issuer arm below, whatever code it uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "enough XLM for the fee" pre-flight picked the paying balance by code, so
any balance using that code satisfied it. Extract the check to
hasEnoughXlmForFee in popup/helpers/balance, anchored on the balance type, and
give it direct tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AssetIcon decided whether to render the bundled Stellar logo from the asset
code alone, while already receiving issuerKey. Pair the two, so an asset that
uses the native code but has its own issuer goes through the normal icon
lookup. Also drop the dead native ternary in handleClick, which receives a
canonical identifier and so could never match the bare code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
History chose the bundled native logo from an asset code alone, and treated a
contract's self-reported symbol as proof of which asset the contract is. Pair
the code with its issuer at the icon sites, and decide a contract token's
nativeness from its contract address, which is the only identifier available in
contract space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asset-detail operation matching folded an asset code and a Horizon asset type
into one variable, so an asset whose code is the native code collected the
account's native operations. Extract operationMatchesAssetKey with the native
and classic arms separated, each testing in its own identifier space, and give
it direct tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… address

The fixture identified its test token by contract address
CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC while separately
having it report the symbol "TEST" — that address is the native SAC on
TESTNET, so once row identity is decided by contract address the row
correctly renders as XLM and the test's own "TEST" assertions fail. Swap in a
genuinely non-native contract address and assert it stays that way, so this
cannot silently regress again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four existing cases only exercised the payment-asset arm. Add path-payment
fixtures whose source and destination legs differ, pinning both directions of
the conflation removal on the source side too: a native-sourced path payment
correctly excludes from a classic asset keyed on the native code and includes
under the true native key, and a path payment sourced from that classic asset
correctly includes under its own key and excludes from the native key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getNativeContractDetails listed the native SAC address for two networks and
returned an empty string for the rest, which degraded every downstream
comparison to a match against "". Derive it from the network passphrase, and
route isAssetSac's native branch through isNativeContract.

Also updates three pre-existing getNativeContractDetails unit tests in
searchAsset.test.js: they called it with network-only fixtures (no
networkPassphrase), which the old table-based implementation tolerated but
the derivation now needs, since it hashes the passphrase unconditionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Looking a token up by the native contract address produced a row carrying the
network table's legacy issuer value, so the row's canonical identifier was a
code/issuer pair rather than the native identifier and never matched the held
native balance. Extract buildNativeAssetRow, which carries no issuer, and gate
the branch on isNativeContract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
List membership compared optional issuer and contract fields directly, so two
absent values matched and an asset could match a list holding a single
contract-less entry. Extract assetMatchesListItem, which requires the asset's
own side of each comparison to be present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds no-asset-code-comparison, which reports a strict equality comparison with
a native-asset sentinel on either side, so a nativeness check goes through a
predicate rather than a string comparison. Not wired into the config yet — the
tree is migrated first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without an explicit type, the plain object literal's meta.type field widens
to string instead of ESLint's RuleType union, so TypeScript rejects passing
the rule to RuleTester.run() (TS2345). Annotate it with the RuleModule type
from ESLint's own types instead of changing its shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pair

useGetAssetDomainsWithBalances treated any balance coded "XLM" as the
native asset, regardless of issuer. Nothing reserves that display code
for the native asset — a classic balance can legitimately carry code
"XLM" with a real issuer — so a non-native asset sharing the code
collapsed into the native row: it lost its real issuer (shown as ""),
was skipped for icon and home-domain lookup, and had its Blockaid
verdict hardcoded to benign instead of reflecting its actual scan data.

Route the check through isNativeAssetPair(code, issuer.key), which
requires both the native code and the absence of an issuer, matching
how every other identity check in the codebase now decides nativeness.
A balance's code alone never establishes its identity; the pair does.

Added a regression test pinning both outcomes on one fixture: a classic
asset coded "XLM" with a real issuer keeps its own issuer, domain, and
Blockaid-derived suspicious flag and is listed separately, while the
genuine native balance keeps its existing native-row behavior
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… predicates

Also collapses two hand-rolled canonical identifiers onto getCanonicalFromAsset,
which already returns the native identifier for a code with no issuer.

Corrects AssetNetworkInfo's assetType prop to a plain string. The prop has no
real callers today; its previous union type resolved through an unrelated
Omit<> modeling issue in ClassicAsset["token"]["type"] (account-balance.ts)
that only surfaced once isNativeAssetId's stricter parameter type replaced a
bare `===` comparison. No behaviour change.

Adds a BalanceRow regression test pinning that a classic asset coded "XLM"
with a real issuer and an iconUrl renders its icon instead of AssetIcon's
perpetual loading state, per the hazard already documented at BalanceRow's
canonical/resolvedIcons comment. Verified red against the pre-fix condition
and green against the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…edicates

Also routes useSimulateTxData's native-SAC derivation through the shared
getNativeContractId helper instead of an inline Asset.native().contractId(...)
call, so there is one derivation site.

useSwapTokenLookup's heldToRecord corrects the brief's literal replacement:
token.issuer is a {key: string} object here (not a plain string), so the
native check uses token.issuer?.key to keep isNativeAssetPair's runtime
truthiness check identical to the original `!token.issuer`, while type-checking
against the predicate's string signature. currencyToRecord's asset.issuer is
already a plain string, so it passes straight through as the brief specified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Includes both SignTransaction sites in the six-caller group that pass an
empty assetIcons map for an XLM-coded classic asset with a real issuer
(assetIcons={code !== "XLM" ? icons : {}}); migrating to isNativeAssetPair
(issuer-aware) instead of a bare code check keeps AssetIcon's isEmpty(...)
check false for that asset, matching the other four callers migrated in the
prior two commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
useGetAssetDomainsWithBalances decided identity for an XLM-coded asset
with isNativeAssetPair(code, issuer.key), but the full AssetType-typed
balance is in unshadowed scope at that line, and isNativeAssetPair is
documented as a last resort for when neither a token type nor a
contract id is available — neither restriction applies here.

Route the check through isNativeBalance(balance) instead. It agrees
with the pair form for NativeAsset and ClassicAsset, and is strictly
more robust for SorobanAsset: a Soroban token's shape carries no `type`
field, so isNativeBalance is false for it unconditionally, while the
pair form would read a Soroban token coded "XLM" with an empty issuer
key as native. isNativeBalance doesn't depend on that key being
non-empty to begin with.

The existing regression test is unaffected, since it exercises
NativeAsset and ClassicAsset, where the two forms already agreed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turns no-asset-code-comparison on at error level now that every call site is
migrated, with @shared/helpers/assetIdentity exempt as the module that defines
the predicates. ESLint runs inside the webpack build, so a new comparison
against a native sentinel fails the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds an anti-patterns section spelling out the (code, issuer)/contract-id
identity rule and which predicate to use for each shape, and records the
enforcing lint rule in the code-style reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n map

TokenList already computes isNative via isNativeBalance(balance), which is
authoritative here since the balance's type is known. The assetIcons ternary
independently re-derived nativeness via isNativeAssetPair(code, issuerKey)
instead of reusing it — isNativeAssetPair is documented as a last resort for
when neither a token type nor a contract id is available, which does not
apply at this call site. Same category as 217be89.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The asset-identity docs claimed ESLint runs inside the webpack build so a
violation fails `yarn build:extension`, full stop. In fact eslint-webpack-plugin
derives its lint glob from the webpack context, which is `<repo>/extension`
under `yarn workspace extension build` -- @shared/, the tree the predicates
themselves live in, is not linted by that build, and there is no root `lint`
script wiring it in either. State that plainly instead of implying a guarantee
the rule doesn't provide; closing the gap is a follow-up.

Also: expand "What it can't do" with the rule's real syntactic blind spots --
it only visits `===`/`!==` binary expressions, so loose equality, switch/case,
template literals, .includes()-style checks, and a sentinel hoisted into a
local const all pass silently; describe the assetIdentity.ts lint exemption as
a belt-and-braces safeguard rather than a necessity, since that module's own
local consts aren't in the rule's identifier list anyway; mark
NATIVE_TOKEN_CODE/HORIZON_NATIVE_ASSET_TYPE as forward-looking names with no
matching constant in this codebase yet (they cover code ported from mobile),
not existing ones; soften code-style.md's claim that lint enforces asset
identity (it only catches native-sentinel comparisons, never identity) and
point its cross-link at the heading's actual anchor.

Also applies the same isNativeBalance(balance) correction from 217be89 and
5159097 to useSwapTokenLookup's heldToRecord, the branch's last remaining
isNativeAssetId(...) || isNativeAssetPair(...) split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stellar.expert returns the native asset as a bare "XLM" record — no issuer
and no domain — and the asset search mapped that to a row carrying neither
an issuer nor a contract id. The verified-list split recognises the native
asset only by its contract id (it seeds the network's native contract into
the verified set), so a row with no identity at all landed under
"Unverified", and the held-balance check could not match it either.

Extract the record-to-row mapping into mapStellarExpertRecord and build the
native row from buildNativeAssetRow, so it carries the derived native
contract id, an empty issuer and the native canonical identifier. Native is
detected with isNativeAssetPair on the split record, the raw-strings case
that predicate exists for; a classic asset that merely uses the code "XLM"
keeps its own issuer and is not treated as native.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-e1df6561adcfcfc6b081
Backend: V1 prod + V2 beta (no sandbox configured for @CassioMG). SDF collaborators only — install instructions in the release description.

@CassioMG
CassioMG marked this pull request as ready for review September 5, 2026 17:15
Copilot AI balanced review requested due to automatic review settings September 5, 2026 17:15
AssetIcon's custom memo comparator only compared the icon map and the two
security flags, so a surviving instance whose asset changed while the icon map
stayed deeply equal kept the previous asset's logo or loading state on
screen. That gap predates this branch — the comparator already ignored the
code the native check used to depend on — and now that the check also reads
the issuer, both halves of the asset's identity have to be compared. Compare
code, issuer, icon and the shape flags alongside the icon map; the retry
callback stays excluded because its identity is unstable and it does not
affect the render. Two rerender tests pin an identity change over an equal
icon map in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 5, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 56 out of 56 changed files in this pull request and generated no new comments.

@CassioMG

CassioMG commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@CassioMG CassioMG self-assigned this Sep 5, 2026
@CassioMG
CassioMG requested a review from a team September 8, 2026 19:29
@piyalbasu

Copy link
Copy Markdown
Contributor

The native check accepts a contract token whose symbol contains a colon

Low severity, pre-existing — not a merge blocker. The behaviour isn't introduced by this PR. Raising it here because the new doc comment asserts the specific invariant that doesn't hold, so this is the natural place to either harden it or correct the comment.

TL;DR: Our asset identifiers are SYMBOL:CONTRACT_ID strings, parsed by splitting on the first colon. A Soroban token's symbol is whatever its contract reports, so it isn't guaranteed to be colon-free the way a classic asset code is. When a symbol contains a colon the split lands in the wrong place, the issuer half comes back empty, and the native check reads the result as real XLM. The new doc comment states this shape can only ever carry a C… issuer and is therefore always rejected — it can also carry an empty string, which is accepted. Classic assets are unaffected: their codes are alphanumeric-only by protocol.

Why it's worth a fix rather than a note: the identifier space isn't colon-safe, but three separate call sites assume it is, each failing differently — one misidentifies the asset as native, one can't extract the contract id at all, one derives an empty asset address. A malformed identifier should fail loudly at the parse boundary instead of flowing onward as a plausible-looking asset.


Detailed explanation (for agents)

Root cause: two independently reasonable behaviours compose badly.

The native pair test treats a falsy issuer as "no issuer", which is correct for real native:

/**
* 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;

But the canonical parser splits on the first colon with a two-element destructure, and isSorobanIssuer("") is true because "".startsWith("G") is false — so a truncated parse yields the plain shape with issuer: "" rather than throwing:

export const isSorobanIssuer = (issuer: string) => !issuer.startsWith("G");
export const getAssetFromCanonical = (canonical: string) => {
if (isNativeAssetId(canonical)) {
return StellarSdk.Asset.native();
}
if (canonical.includes(":")) {
const [code, issuer] = canonical.split(":");
if (isSorobanIssuer(issuer)) {
return {
code,
issuer,
};
}
return new StellarSdk.Asset(code, issuer);
}
throw new Error(`invalid asset canonical id: ${canonical}`);
};

Which makes the invariant asserted here untrue — the plain shape does not "only ever carry a C… issuer":

/**
* True only for the native asset.
*
* `getAssetFromCanonical` returns an SDK `Asset` for classic assets and a plain
* `{ code, issuer }` for Soroban issuers, so both shapes arrive here. The SDK's
* own `isNative()` is authoritative when present; the plain shape only ever
* carries a `C…` issuer, which the pair test correctly rejects.
*
* 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 round trip, for a symbol of XLM: and contract CAS3…OWMA:

getCanonicalFromAsset("XLM:", "CAS3…OWMA")  →  "XLM::CAS3…OWMA"
getAssetFromCanonical("XLM::CAS3…OWMA")     →  split(":") = ["XLM", "", "CAS3…OWMA"]
                                            →  { code: "XLM", issuer: "" }
isNativeAsset({ code: "XLM", issuer: "" })  →  isNativeAssetPair("XLM", "")  →  true

Why a colon can occur. Contract-token canonicals are built by string-concatenating the symbol:

);
const total = new BigNumber(balance);
tokenBalances[`${symbol}:${tokenId}`] = {
token: { issuer: { key: tokenId }, code: symbol },
contractId: tokenId,
total,
symbol,
...rest,
};

}
if ("contractId" in bal && "symbol" in bal) {
assetOperationMap[
getCanonicalFromAsset(bal.symbol, bal.contractId || "")
] = [];
}

…and the symbol is the raw simulation result of symbol() on a third-party contract, with no normalization on the way in:

export const getSymbol = async (
contractId: string,
server: SorobanRpc.Server,
builder: TransactionBuilder,
) => {
const contract = new Contract(contractId);
const tx = builder
.addOperation(contract.call("symbol"))
.setTimeout(TimeoutInfinite)
.build();
const result = await simulateTx<string>(tx, server);
return result;
};

Search-result rows have the same exposure, sourcing code from stellar.expert's record.code or an issuer's tomlInfo.code. I grepped for asset-code normalization and there is none in the repo. Classic assets can't reach this — [a-zA-Z0-9]{1,12} is enforced by the SDK's Asset constructor.

Where it surfaces. The single production caller of isNativeAsset chooses between a token payment and an XLM-funded createAccount:

// create account if unfunded and sending the native asset
if (!isFunded && isNativeAsset(sourceAsset)) {
let createAccountDestination = destination;
if (isMuxedAccount(destination)) {
// encode muxed account to address
createAccountDestination = extractBaseAddress(destination);
}
return Operation.createAccount({
destination: createAccountDestination,
startingBalance: amount,
});
}

Behaviour differs by entry point:

  • Asset pickerisToken is derived from the un-round-tripped contract id, so it's true and the flow diverts to the Soroban path before getOperation runs. Not affected.
  • Send deep linkisToken is derived from the parsed asset, whose issuer is now "", so it's false and the classic path is taken:
    if (assetParam) {
    try {
    const asset = getAssetFromCanonical(assetParam);
    dispatch(saveAsset(assetParam));
    dispatch(saveIsCollectible(false));
    dispatch(saveIsToken(isContractId(asset.issuer)));
    } catch {
    // Invalid asset param: keep current asset/flags when already selected.
    . Worth noting this path already accepts asset=native directly, so the parse quirk doesn't widen what a crafted URL can preselect.
  • Most likely observed symptom — the contract-id extractor requires exactly two segments, so a colon-bearing symbol yields undefined, the token falls through to classic handling, and it renders with code XLM and an empty asset address:
    return tokenId;
    }
    // Check if it's SYMBOL:CONTRACTID format (Soroban token)
    // Split by : and check if the second part is a contract ID
    const parts = tokenId.split(":");
    if (parts.length === 2 && isContractId(parts[1])) {
    // This is a Soroban token in SYMBOL:CONTRACTID format
    return parts[1];
    }
    // Classic token format: CODE:ISSUER (no contract ID)
    return undefined;

Adjacent, same shape: getCanonicalFromAsset(bal.symbol, bal.contractId || "") maps a symbol-XLM token with a missing contract id straight to the key "native".

Repro as a unit test — drop into @shared/helpers/__tests__/assetIdentity.test.ts alongside the new isNativeAsset cases; it fails on this branch and needs nothing deployed:

// add to the existing imports:
// import { getAssetFromCanonical, getCanonicalFromAsset } from "@shared/helpers/stellar";

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

Suggested fixes (in increasing order of depth):

  1. Cheap guard, wrong layer: tighten the pair test to require issuer == null instead of any falsy issuer. Don't"" legitimately means "no issuer" for native elsewhere (buildNativeAssetRow sets issuer: "", and getCanonicalFromAsset maps a falsy issuer to "native"), so this would break the native search row and the native canonical.
  2. Fix the parse boundary: in getAssetFromCanonical, split on the last colon and require the issuer half to be a valid G…/C… StrKey, throwing invalid asset canonical id otherwise. A malformed identifier then fails loudly instead of becoming a silently mangled asset, and every consumer is fixed at once — including the contract-id extractor and the asset-address derivation.
  3. Close it at the source: reject codes containing : in getCanonicalFromAsset and normalize the symbol when a Soroban balance is constructed, so a colon can't enter the identifier space at all. Only this option also resolves the display-layer confusion.

Correcting the doc comment on isNativeAsset is worth doing regardless of which fix lands, since it currently documents an invariant the code doesn't enforce.

@piyalbasu

Copy link
Copy Markdown
Contributor

Test comment describes hardcoding this PR already removed

Trivial — not a merge blocker. Comment-only, no runtime effect. Raising it because it's provably wrong in its own commit rather than merely debatable, and it's a one-line edit.

TL;DR: The comment above the pinned native SAC addresses says they mirror values a helper hardcodes "before Task 8 removes them" — but this PR is what removed that hardcoding, so the comment describes a state that no longer exists as of this commit. It also points at an internal task-plan step that appears nowhere else in the repository, which won't mean anything to the next reader.


Detailed explanation (for agents)

The comment:

"GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
// The published native SAC addresses. Pinned rather than re-derived, so these
// tests also assert that deriving from the passphrase reproduces the values
// getNativeContractDetails hardcodes before Task 8 removes them.
const NATIVE_SAC_PUBLIC =
"CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
const NATIVE_SAC_TESTNET =
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";

The contract id is already derived from the passphrase in this same PR, so nothing is hardcoding it "before Task 8":

org: "",
};
// The native SAC address derives deterministically from the network
// passphrase, which keeps every network correct by construction.
const contract = getNativeContractId(networkDetails.networkPassphrase);
switch (networkDetails.network as keyof typeof NETWORKS) {

git grep "Task 8" over the tree at this head returns exactly one hit — the comment itself.

Note the issuer field on the PUBLIC branch of that helper is still a literal, but that's a different field from the contract id the comment is about.

Suggested replacement — keeps the reason the values are pinned, drops the stale forward reference:

// 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.

@piyalbasu

Copy link
Copy Markdown
Contributor

The classic-asset token type accepts any string, including "native"

Pre-existing, not a merge blocker — and the affected file isn't in this PR. Filing it here because this PR is the first code to route that field through a typed helper, which is what surfaces it, and because a reviewer looking at the new double cast deserves to know the cast isn't the problem.

TL;DR: The balance type that should mean "any asset type except native" is built with Omit over a string enum. Omit removes keys, and a string union's keys are String's methods — so nothing is removed and the result is an object type that accepts any string at all, "native" included. The type therefore fails at its only job, and that's why the new native-balance predicate needs a double cast to compare it: as far as the compiler is concerned the field isn't a string. Exclude is the operator that does what was intended here, and switching to it lets the cast go away entirely.


Detailed explanation (for agents)

The type, unchanged since before this PR:

export interface ClassicAsset {
token: {
type: Omit<SdkAssetType, "native">;
code: string;
issuer: { key: string };
};
available: BigNumber;
total: BigNumber;
buyingLiabilities: string;
sellingLiabilities: string;
blockaidData: Blockaid.TokenScanResponse;

The cast it forces, new in this PR:

/** 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 as unknown as string);

Why. SdkAssetType is a string enum, and Omit<T, K> expands to Pick<T, Exclude<keyof T, K>>. keyof a string-enum union yields String's prototype members (toString, charAt, length, …), none of which is "native" — so nothing is omitted, and the result is an object type describing String's shape. Any string structurally satisfies it. Verified against the repo's own TypeScript:

type Omitted  = Omit<SdkAssetType, "native">;
type Excluded = Exclude<SdkAssetType, "native">;

const a: Omitted  = "native";                    // no error  ← the value it exists to exclude
const b: Omitted  = "totally-not-an-asset-type"; // no error  ← any string at all
const c: Excluded = "native";                    // TS2322
const d: Excluded = "totally-not-an-asset-type"; // TS2322

Scope of the fix. git grep "token\.type" finds exactly one production consumer — the predicate linked above. So:

-    type: Omit<SdkAssetType, "native">;
+    type: Exclude<SdkAssetType, "native">;
-  isNativeAssetId(balance.token.type as unknown as string);
+  isNativeAssetId(balance.token.type);

After that, the narrowed field is SdkAssetType.native | Exclude<SdkAssetType, "native">, which is assignable to string, so the predicate typechecks unaided — and comparing a ClassicAsset's type against "native" becomes a compile error (TS2367, no overlap) instead of something a cast waves through, which is the outcome this PR's whole convention is after.

Why it predates this PR. @shared/api/types/account-balance.ts is not among this PR's changed files, and the Omit is present at the merge base (2d8876c). At that base the same read was a bare balance.token.type === "native" in extension/src/popup/helpers/balance.ts, which compiled only because comparing that object type to a string was permitted. Correctly out of scope for this PR's findings — worth its own two-line change.

CassioMG and others added 3 commits September 9, 2026 13:44
…te the issuer

A contract token's symbol is whatever its contract reports, so it is not
guaranteed colon-free the way a classic asset code is. Canonical identifiers
were split on the first colon, so a symbol containing one put the split in the
wrong place and left an empty issuer half, which the Soroban branch accepted
because its only check was "does not start with G". Add splitCanonical, which
splits on the last colon (an issuer is a G or C StrKey or the pool sentinel and
never contains one), and have getAssetFromCanonical require the issuer half to
be a contract address, the pool sentinel, or a public key — anything else is
rejected at the parse boundary. Route the other canonical parses through the
same helper, and correct the isNativeAsset doc comment, which asserted the
plain shape only ever carries a C address.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ClassicAsset's token.type was written as Omit<SdkAssetType, "native">. Omit
removes keys, and a string enum's keys are String's prototype members, so
nothing was removed and the field accepted any string — "native" included —
which is why the native-balance predicate needed a double cast to compare it.
Use Exclude, which operates on the union members, and type the v2 API's
CLASSIC and SAC token.type the same way at the boundary (the API already
discriminates native with token_type: "NATIVE"). The cast goes away, and
comparing a classic token's type against "native" is now a compile error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s cases plainly

The comment above the pinned native SAC addresses referred to an internal
task-plan step and to hardcoding that no longer exists at this commit. Say
why the values are pinned instead. Rename the derived-address cases to state
what they check rather than what preceded them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 9, 2026 20:11
@CassioMG

CassioMG commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Re: the native check accepts a contract token whose symbol contains a colon

Reproduced — your unit test fails on the previous head exactly as written. Fixed in feea4235 with your fix #2, at the parse boundary.

TL;DR: splitCanonical now splits on the last colon, and getAssetFromCanonical requires the issuer half to be a contract address, a public key, or the liquidity-pool sentinel — anything else throws invalid asset canonical id instead of flowing on as a mangled asset. The other six canonical parses route through the same helper, so the identifier space is colon-safe consistently rather than in one function. The isNativeAsset doc comment is corrected too.

One wrinkle worth recording, since it would bite anyone implementing this from the description: the pool sentinel is a legitimate non-StrKey issuer. LP shares are keyed <poolId>:lp and reach the parser through getBalanceByAsset, so a bare "must be a StrKey" check would have thrown for every LP holder. It's allowlisted explicitly and pinned by a test.

Details
  • splitCanonical
    export const splitCanonical = (
    canonical: string,
    ): { code: string; issuer: string } => {
    const separator = canonical.lastIndexOf(":");
    if (separator === -1) {
    return { code: canonical, issuer: "" };
    }
    return {
    code: canonical.slice(0, separator),
    issuer: canonical.slice(separator + 1),
    };
    };
    /**
    * Resolves a canonical identifier to an asset.
    *
    * A contract token or a liquidity-pool share comes back as the plain
    * `{ code, issuer }` shape; a classic asset comes back as an SDK `Asset`, whose
    * constructor validates the code and issuer. Anything else — including an
    * issuer half that is empty or not a StrKey — is rejected here, at the parse
    * boundary, rather than flowing on as a plausible-looking asset.
  • Validating parser, including the pool-sentinel arm —
    export const getAssetFromCanonical = (canonical: string) => {
    if (isNativeAssetId(canonical)) {
    return StellarSdk.Asset.native();
    }
    const { code, issuer } = splitCanonical(canonical);
    if (!issuer) {
    throw new Error(`invalid asset canonical id: ${canonical}`);
    }
    if (StellarSdk.StrKey.isValidContract(issuer) || issuer === LP_ISSUER_KEY) {
    return { code, issuer };
    }
    if (StellarSdk.StrKey.isValidEd25519PublicKey(issuer)) {
    return new StellarSdk.Asset(code, issuer);
    }
    throw new Error(`invalid asset canonical id: ${canonical}`);
    };
    export const getCanonicalFromAsset = (
    assetCode: string,
    assetIssuer?: string,
    ) => {
    if (isNativeAssetPair(assetCode, assetIssuer)) {
    return "native";
  • Routed through it: getContractIdFromTokenId and isSacContract (popup/helpers/soroban.ts), getAssetSacAddress (%40shared/helpers/soroban/token.ts), getAssetAddress (useSimulateTxData), sendWarnings, the hidden-asset keys in popup/helpers/account.ts, and the icon batching in %40shared/api/internal.ts.
  • Your repro, as a test —
    it("rejects a contract token whose symbol contains a colon", () => {
    const contract = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
    const canonical = getCanonicalFromAsset("XLM:", contract);
    expect(isNativeAsset(getAssetFromCanonical(canonical))).toBe(false);
    });
  • Eight parser cases including the LP sentinel and the rejection paths —
    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();
    });
    });

Full suite 1860 passed / 0 failed; yarn build:extension exit 0.

@CassioMG

CassioMG commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Re: the classic-asset token type accepts any string

Confirmed against the repo's tscOmit accepts both "native" and "totally-not-an-asset-type" without error, Exclude rejects both (TS2322). Fixed in b81e3324; the double cast is gone.

TL;DR: Switching to Exclude immediately made the build fail — which is the useful part. The v2 balance mapper feeds a string-typed token.type straight into the classic shape, and that assignment only ever compiled because the type was loose. So the fix is two lines in account-balance.ts plus typing the v2 API boundary the same way; no cast anywhere, and the mapper is now checked rather than merely permitted.

Details
  • OmitExclude
    type: Exclude<SdkAssetType, "native">;
  • Cast removed from the predicate —
    export const isNativeBalance = (balance: AssetType): balance is NativeAsset =>
    "token" in balance &&
    "type" in balance.token &&
    isNativeAssetId(balance.token.type);
  • The build failure it surfaced: mapClassic / mapSac in %40shared/api/helpers/mapAccountBalancesV2.ts (TS2322, Type 'string' is not assignable to '"credit_alphanum4" | "credit_alphanum12" | "liquidity_pool_shares"'). Fixed at the boundary rather than with a cast — V2ClassicBalance and V2SacBalance now declare type: Exclude<SdkAssetType, "native">, matching how that file already literal-types the native branch (token: { type: "native"; code: "XLM" }) and discriminates with token_type: "NATIVE":
    token: {
    type: Exclude<SdkAssetType, "native">;
    code: string;
    issuer: V2TokenIssuer;
    };

As you noted, comparing a ClassicAsset's type against "native" is now TS2367 rather than something a cast waves through.

@CassioMG

CassioMG commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Re: test comment describes hardcoding this PR already removed

Replaced with your wording in 66e102b1. The Task 8 reference was a leak from the internal plan this branch was written against — it should never have reached the tree.

TL;DR: A sweep of the whole diff for the same shape found four more spots narrating the previous behaviour rather than stating what the test checks — three test names ("a network the old table omitted", "a network the table doesn't cover", "the table omitted") and one comment about "the empty string the table used to fall back to". All rephrased in the same commit; the assertions are untouched.

Details
  • Pinned-address comment, your replacement —
    // predicate has to say so.
    const XLM_CODED_ISSUER =
    "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
  • Also rephrased: assetIdentity.test.ts ("derives a distinct address on FUTURENET"), searchAsset.test.js (name + the fall-back comment), searchAsset.test.ts, soroban.test.ts — each now names the network and behaviour under test instead of what preceded it.
  • git grep "Task 8" over the tree now returns nothing.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 61 out of 61 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

extension/src/popup/helpers/assetList.ts:97

  • Matching only issuer does not establish asset identity: one Stellar account can issue multiple asset codes. As written, getAssetListsForAsset reports a list for an unlisted asset whenever that list contains a different asset from the same issuer, and AddAsset then treats the token as verified. Compare (code, issuer) for classic assets (while retaining contract-id matching), and update the new shared-issuer test to cover different codes.

Copilot AI review requested due to automatic review settings September 9, 2026 21:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 61 out of 61 changed files in this pull request and generated 1 comment.

Comment thread extension/src/popup/helpers/assetList.ts
assetMatchesListItem's docstring claimed it is true when two records "name the
same asset". It compares one half of an identity: two records sharing an issuer
match even when their codes differ, which for a classic asset means a different
asset. Describe what it compares, and point both the docstring and the
shared-issuer test at stellar/wallet-eng-monorepo#76, where that behaviour is
being assessed. The test now states that it pins current behaviour rather than
the intended contract, so a fix for #76 is not mistaken for a regression.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 9, 2026 22:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 61 out of 61 changed files in this pull request and generated no new comments.

@CassioMG
CassioMG merged commit fc37b69 into master Sep 10, 2026
12 checks passed
@CassioMG
CassioMG deleted the chore/consolidate-asset-identity-checks branch September 10, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants