From 4529fe86c51a89fe05f8e86c4bbb5bfb935445e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:40:40 +0000 Subject: [PATCH] fix: verify classic assets by code and issuer, not issuer alone An asset was shown as verified when any asset list entry shared its issuer, without checking the asset code. So every code an issuer minted was verified when the list named only one of them (for example USDe from the issuer of the listed USDE). Classic assets now match a list entry only when both code and issuer match. Contract tokens still match by contract ID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014B2zysccigTczhF1iiZNuu --- .../popup/helpers/__tests__/assetList.test.ts | 96 +++++++++++++++++-- extension/src/popup/helpers/assetList.ts | 60 +++++++----- 2 files changed, 124 insertions(+), 32 deletions(-) diff --git a/extension/src/popup/helpers/__tests__/assetList.test.ts b/extension/src/popup/helpers/__tests__/assetList.test.ts index 9d01ed8547..9d43acb18f 100644 --- a/extension/src/popup/helpers/__tests__/assetList.test.ts +++ b/extension/src/popup/helpers/__tests__/assetList.test.ts @@ -12,19 +12,33 @@ const ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; const CONTRACT = "CCV3NAKLIBBNSJNNTV2AZVRX6VODUDWK4TVYILE5MW6R45SSQJS5VCAM"; describe("assetMatchesListItem", () => { - // Issuer-alone matching is the long-standing behaviour of the verified-list - // membership tests, tracked in stellar/wallet-eng-monorepo#76. Pinned here as - // current behaviour, not as the intended contract — a fix for that issue is - // expected to require the code to match too, and to change this expectation. - it("matches on a shared issuer, without comparing the code", () => { + it("matches on a shared code and issuer", () => { expect( assetMatchesListItem( - { issuer: ISSUER }, - { issuer: ISSUER, contract: undefined }, + { code: "USDE", issuer: ISSUER }, + { code: "USDE", issuer: ISSUER, contract: undefined }, ), ).toBe(true); }); + it("does not match a different code from the same issuer", () => { + expect( + assetMatchesListItem( + { code: "USDe", issuer: ISSUER }, + { code: "USDE", issuer: ISSUER }, + ), + ).toBe(false); + }); + + it("does not match on the issuer alone", () => { + expect( + assetMatchesListItem( + { issuer: ISSUER }, + { code: "USDE", issuer: ISSUER }, + ), + ).toBe(false); + }); + it("matches on a shared contract", () => { expect( assetMatchesListItem({ contract: CONTRACT }, { contract: CONTRACT }), @@ -37,7 +51,10 @@ describe("assetMatchesListItem", () => { it("does not match a contract-less asset against a contract-less entry", () => { expect( - assetMatchesListItem({ issuer: ISSUER }, { issuer: "GDIFFERENT" }), + assetMatchesListItem( + { code: "USDE", issuer: ISSUER }, + { code: "USDE", issuer: "GDIFFERENT" }, + ), ).toBe(false); }); }); @@ -100,4 +117,67 @@ describe("splitVerifiedAssetCurrency", () => { expect(verifiedAssets).toEqual([]); expect(unverifiedAssets).toEqual([xlmCodedRow]); }); + it("verifies only the listed code from a listed issuer", async () => { + const listedRow = mapStellarExpertRecord( + { asset: `USDE-${ISSUER}-1` }, + MAINNET, + ); + const sameIssuerRow = mapStellarExpertRecord( + { asset: `USDe-${ISSUER}-1` }, + MAINNET, + ); + jest.spyOn(TokenListHelpers, "schemaValidatedAssetList").mockResolvedValue({ + assets: [ + { + code: "USDE", + issuer: ISSUER, + contract: CONTRACT, + domain: "", + icon: "", + decimals: 7, + }, + ], + errors: null, + }); + + const { verifiedAssets, unverifiedAssets } = + await splitVerifiedAssetCurrency({ + networkDetails: MAINNET, + assets: [listedRow, sameIssuerRow], + assetsListsDetails: {} as never, + cachedAssetLists: [emptyList], + }); + + expect(verifiedAssets).toEqual([listedRow]); + expect(unverifiedAssets).toEqual([sameIssuerRow]); + }); + + it("verifies a contract token listed by contract id", async () => { + const contractRow = mapStellarExpertRecord( + { asset: CONTRACT, code: "TKN" }, + MAINNET, + ); + jest.spyOn(TokenListHelpers, "schemaValidatedAssetList").mockResolvedValue({ + assets: [ + { + code: "TKN", + issuer: "", + contract: CONTRACT, + domain: "", + icon: "", + decimals: 7, + }, + ], + errors: null, + }); + + const { verifiedAssets } = await splitVerifiedAssetCurrency({ + networkDetails: MAINNET, + assets: [contractRow], + assetsListsDetails: {} as never, + cachedAssetLists: [emptyList], + }); + + expect(verifiedAssets).toEqual([contractRow]); + }); }); diff --git a/extension/src/popup/helpers/assetList.ts b/extension/src/popup/helpers/assetList.ts index 78f07f5668..c9a6642eeb 100644 --- a/extension/src/popup/helpers/assetList.ts +++ b/extension/src/popup/helpers/assetList.ts @@ -1,4 +1,5 @@ import { NetworkDetails } from "@shared/constants/stellar"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { AssetsLists, AssetListReponseItem, @@ -36,15 +37,10 @@ export const splitVerifiedAssetCurrency = async ({ validatedAssets.push(...validatedList.assets); } } - // make a unique set of contract IDs and issuers + // make a unique set of the contract IDs and code:issuer pairs the lists vouch for const verifiedIds = new Set(); for (const validAsset of validatedAssets) { - if (validAsset.contract && !verifiedIds.has(validAsset.contract)) { - verifiedIds.add(validAsset.contract); - } - if (!verifiedIds.has(validAsset.issuer)) { - verifiedIds.add(validAsset.issuer); - } + getAssetIdentities(validAsset).forEach((id) => verifiedIds.add(id)); } verifiedIds.add(nativeContractDetails.contract); @@ -53,12 +49,7 @@ export const splitVerifiedAssetCurrency = async ({ [typeof assets, typeof assets] >( ([inVerifiedList, notInVerifiedList], item) => { - if (item.issuer && verifiedIds.has(item.issuer)) { - inVerifiedList.push(item); - return [inVerifiedList, notInVerifiedList]; - } - - if (item.contract && verifiedIds.has(item.contract)) { + if (getAssetIdentities(item).some((id) => verifiedIds.has(id))) { inVerifiedList.push(item); return [inVerifiedList, notInVerifiedList]; } @@ -83,23 +74,44 @@ interface GetAssetListsForAssetParams { } /** - * True when `asset` and `item` share an issuer, or share a contract id. + * The identities an asset (or an asset list entry) is known by: its contract + * id, and for a classic asset its code:issuer pair. + * + * A classic asset is the pair (code, issuer), so the issuer alone is never an + * identity: an issuer can mint any number of codes, and a list that vouches + * for one of them does not vouch for the others. Codes are case-sensitive, so + * USDE and USDe from the same issuer are different assets. + */ +export const getAssetIdentities = (asset: { + code?: string; + issuer?: string; + contract?: string; +}): string[] => { + const identities = [] as string[]; + if (asset.contract) { + identities.push(asset.contract); + } + if (asset.code && asset.issuer) { + identities.push(getCanonicalFromAsset(asset.code, asset.issuer)); + } + return identities; +}; + +/** + * True when `asset` and `item` share a contract id, or share both code and + * issuer. * * Both halves of an identity are optional on these records, so each comparison * requires its own side to actually be present — two absent values are not a * match. - * - * Note this is not full identity: a classic asset is the pair (code, issuer), - * and matching on the issuer alone accepts a different asset from the same - * issuer. That is the long-standing behaviour of the verified-list membership - * tests, tracked in stellar/wallet-eng-monorepo#76; this helper preserves it. */ export const assetMatchesListItem = ( - asset: { issuer?: string; contract?: string }, - item: { issuer?: string; contract?: string }, -): boolean => - (!!asset.issuer && asset.issuer === item.issuer) || - (!!asset.contract && asset.contract === item.contract); + asset: { code?: string; issuer?: string; contract?: string }, + item: { code?: string; issuer?: string; contract?: string }, +): boolean => { + const itemIdentities = getAssetIdentities(item); + return getAssetIdentities(asset).some((id) => itemIdentities.includes(id)); +}; export const getAssetListsForAsset = async ({ asset,