diff --git a/@shared/api/helpers/getIconFromTokenList.ts b/@shared/api/helpers/getIconFromTokenList.ts index ae97610db8..aa42c39169 100644 --- a/@shared/api/helpers/getIconFromTokenList.ts +++ b/@shared/api/helpers/getIconFromTokenList.ts @@ -20,7 +20,11 @@ export const getIconFromTokenLists = async ({ }) => { let verifiedToken = {} as AssetListReponseItem; let canonicalAsset = undefined as string | undefined; - for (const data of assetsListsData) { + // 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) { @@ -29,7 +33,7 @@ export const getIconFromTokenLists = async ({ if (record.contract && record.contract.match(regex) && record.icon) { verifiedToken = record; canonicalAsset = getCanonicalFromAsset(code, contractId); - break; + break listLoop; } } @@ -42,7 +46,7 @@ export const getIconFromTokenLists = async ({ ) { verifiedToken = record; canonicalAsset = getCanonicalFromAsset(code, issuerId); - break; + break listLoop; } } } diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 17ebd8546b..e67811cadc 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2081,6 +2081,34 @@ export const getTokenIds = async ({ return tokenIdList; }; +export const getUsdt0LaunchBannerDismissed = async (): Promise => { + const { isDismissed, error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.GET_USDT0_LAUNCH_BANNER_DISMISSED, + }); + + if (error) { + throw new Error(error); + } + + return !!isDismissed; +}; + +export const dismissUsdt0LaunchBanner = async (): Promise<{ + isDismissed: boolean; +}> => { + const { isDismissed, error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.DISMISS_USDT0_LAUNCH_BANNER, + }); + + if (error) { + throw new Error(error); + } + + return { isDismissed: !!isDismissed }; +}; + export const removeTokenId = async ({ activePublicKey, contractId, diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 5dc83dd06a..53db55f049 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -428,6 +428,14 @@ export interface GetHiddenAssetsMessage extends BaseMessage { type: SERVICE_TYPES.GET_HIDDEN_ASSETS; } +export interface GetUsdt0LaunchBannerDismissedMessage extends BaseMessage { + type: SERVICE_TYPES.GET_USDT0_LAUNCH_BANNER_DISMISSED; +} + +export interface DismissUsdt0LaunchBannerMessage extends BaseMessage { + type: SERVICE_TYPES.DISMISS_USDT0_LAUNCH_BANNER; +} + export interface GetRecentProtocolsMessage extends BaseMessage { type: SERVICE_TYPES.GET_RECENT_PROTOCOLS; } @@ -565,6 +573,8 @@ export type ServiceMessageRequest = | GetIsAccountMismatchMessage | ChangeAssetVisibilityMessage | GetHiddenAssetsMessage + | GetUsdt0LaunchBannerDismissedMessage + | DismissUsdt0LaunchBannerMessage | GetRecentProtocolsMessage | AddRecentProtocolMessage | ClearRecentProtocolsMessage diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index d788684012..9c967543a3 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -54,6 +54,8 @@ export enum SERVICE_TYPES { CHANGE_ASSET_VISIBILITY = "CHANGE_ASSET_VISIBILITY", GET_HIDDEN_ASSETS = "GET_HIDDEN_ASSETS", GET_IS_ACCOUNT_MISMATCH = "GET_IS_ACCOUNT_MISMATCH", + GET_USDT0_LAUNCH_BANNER_DISMISSED = "GET_USDT0_LAUNCH_BANNER_DISMISSED", + DISMISS_USDT0_LAUNCH_BANNER = "DISMISS_USDT0_LAUNCH_BANNER", GET_BLOCKAID_DEBUG_OVERRIDE = "GET_BLOCKAID_DEBUG_OVERRIDE", SAVE_BLOCKAID_DEBUG_OVERRIDE = "SAVE_BLOCKAID_DEBUG_OVERRIDE", ADD_COLLECTIBLE = "ADD_COLLECTIBLE", diff --git a/extension/package.json b/extension/package.json index af9e975b96..f2176ef83d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "extension", - "version": "5.47.0", + "version": "5.48.0", "license": "Apache-2.0", "prettier": "../.prettierrc.yaml", "scripts": { diff --git a/extension/public/static/manifest/v3.json b/extension/public/static/manifest/v3.json index 3f17e4fd7c..c2790f8268 100644 --- a/extension/public/static/manifest/v3.json +++ b/extension/public/static/manifest/v3.json @@ -1,7 +1,7 @@ { "name": "Freighter", - "version": "5.47.0", - "version_name": "5.47.0", + "version": "5.48.0", + "version_name": "5.48.0", "description": "Freighter is a non-custodial wallet extension that enables you to sign Stellar transactions via your browser.", "browser_specific_settings": { "gecko": { diff --git a/extension/src/background/messageListener/handlers/__tests__/getCachedAssetIconList.test.ts b/extension/src/background/messageListener/handlers/__tests__/getCachedAssetIconList.test.ts new file mode 100644 index 0000000000..3dbbf4b402 --- /dev/null +++ b/extension/src/background/messageListener/handlers/__tests__/getCachedAssetIconList.test.ts @@ -0,0 +1,45 @@ +import { getCachedAssetIconList } from "../getCachedAssetIconList"; + +const USDT0 = "USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q"; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC_ICON = "https://centre.io/usdc.png"; + +const makeLocalStore = (assetIconCache: unknown) => + ({ + getItem: jest.fn(async () => assetIconCache), + setItem: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }) as any; + +describe("getCachedAssetIconList", () => { + it("returns the icons it has", async () => { + const result = await getCachedAssetIconList({ + localStore: makeLocalStore({ [USDC]: USDC_ICON }), + }); + + expect(result.icons).toEqual({ [USDC]: USDC_ICON }); + }); + + it("leaves out assets recorded as having no icon", async () => { + // A null here means an earlier lookup came up empty, and getAssetIcons + // reads it as "already tried, don't look again". Because this cache is on + // disk that verdict outlived the session, so an asset whose icon failed + // once — USDT0, whose LOBSTR url 403s browsers — stayed iconless forever. + // Dropping nulls turns it back into an ordinary cache miss, and the fresh + // lookup overwrites the stale entry. + const result = await getCachedAssetIconList({ + localStore: makeLocalStore({ [USDC]: USDC_ICON, [USDT0]: null }), + }); + + expect(result.icons).toEqual({ [USDC]: USDC_ICON }); + }); + + it("returns an empty map when nothing is cached", async () => { + const result = await getCachedAssetIconList({ + localStore: makeLocalStore(undefined), + }); + + expect(result.icons).toEqual({}); + }); +}); diff --git a/extension/src/background/messageListener/handlers/dismissUsdt0LaunchBanner.ts b/extension/src/background/messageListener/handlers/dismissUsdt0LaunchBanner.ts new file mode 100644 index 0000000000..fd163574fa --- /dev/null +++ b/extension/src/background/messageListener/handlers/dismissUsdt0LaunchBanner.ts @@ -0,0 +1,11 @@ +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { USDT0_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; + +export const dismissUsdt0LaunchBanner = async ({ + localStore, +}: { + localStore: DataStorageAccess; +}): Promise<{ isDismissed: boolean }> => { + await localStore.setItem(USDT0_LAUNCH_BANNER_DISMISSED, true); + return { isDismissed: true }; +}; diff --git a/extension/src/background/messageListener/handlers/getCachedAssetIconList.ts b/extension/src/background/messageListener/handlers/getCachedAssetIconList.ts index e27161ffb5..000c55f196 100644 --- a/extension/src/background/messageListener/handlers/getCachedAssetIconList.ts +++ b/extension/src/background/messageListener/handlers/getCachedAssetIconList.ts @@ -10,6 +10,52 @@ export const getCachedAssetIconList = async ({ (await localStore.getItem(CACHED_ASSET_ICONS_ID)) || {}; return { - icons: assetIconCache, + // A null entry records that a lookup came up empty, and getAssetIcons reads + // one as "already tried, don't look again". That was only ever meant to last + // a session, but this cache is on disk, so the verdict outlived it: an asset + // whose icon failed once stayed iconless for good, with no way back — no + // icon means no , so nothing fires the error handler that would have + // retried. Dropping nulls here turns them back into ordinary cache misses, + // and the fresh lookup overwrites the stale entry. + // + // TODO: this is a read-side workaround, not the real fix. It hides bad data + // rather than stopping it being written, and it rules out ever storing a + // meaningful null here. + // + // Retrying is the point: it is how an asset that got stuck gets its icon + // back. But the retry is not free, and for an asset with no icon anywhere + // it repeats forever without ever succeeding. Each attempt costs a + // token-list scan and, when that finds nothing, one batched Horizon call + // covering every such issuer plus a stellar.toml fetch for each issuer that + // publishes a home domain. (USDT0 stops at the Horizon call: its issuer + // publishes no home domain and, with its master key at weight 0, never + // can.) + // + // That cost is newly paid by the flows that load balances with icons — + // swap, send, manage assets, history — which previously skipped a + // null-marked asset outright. The Account view's own icon hook already + // retried regardless, since it passes an empty cache to its lookup pass, so + // nothing changes there. And nothing suppresses the retry from one popup to + // the next: closing the popup tears it down, taking Redux — the only record + // of what this session already resolved — with it, so every open starts + // from nothing. + // + // A negative cache that survives the popup, with a TTL so it expires rather + // than latching, is the sane way to stop the endless retry — and this + // filter would silently swallow one. + // + // To fix properly, in order: + // 1. Stop persisting nulls: retryAssetIcon sends `iconUrl: null` meaning + // "clear this", so cacheAssetIcon should delete the entry rather than + // store the null. + // 2. Add a migration to clear the nulls already on disk. It cannot be + // done by (1) alone, since nothing rewrites an entry the lookup skips. + // 3. Drop this filter, so the read path goes back to being a plain + // accessor and null is free to mean something again. + // Doing (2) without (1) is not enough on its own: every later icon failure + // writes a fresh null and puts that user straight back into the bug. + icons: Object.fromEntries( + Object.entries(assetIconCache).filter(([, iconUrl]) => iconUrl), + ), }; }; diff --git a/extension/src/background/messageListener/handlers/getUsdt0LaunchBannerDismissed.ts b/extension/src/background/messageListener/handlers/getUsdt0LaunchBannerDismissed.ts new file mode 100644 index 0000000000..d0e9d6f350 --- /dev/null +++ b/extension/src/background/messageListener/handlers/getUsdt0LaunchBannerDismissed.ts @@ -0,0 +1,11 @@ +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { USDT0_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; + +export const getUsdt0LaunchBannerDismissed = async ({ + localStore, +}: { + localStore: DataStorageAccess; +}): Promise<{ isDismissed: boolean }> => { + const dismissed = await localStore.getItem(USDT0_LAUNCH_BANNER_DISMISSED); + return { isDismissed: !!dismissed }; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index a3b42240e9..68a66236a0 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -90,6 +90,8 @@ import { modifyAssetsList } from "./handlers/modifyAssetsList"; import { getIsAccountMismatch } from "./handlers/getIsAccountMismatch"; import { changeAssetVisibility } from "./handlers/changeAssetVisibility"; import { getHiddenAssets } from "./handlers/getHiddenAssets"; +import { getUsdt0LaunchBannerDismissed } from "./handlers/getUsdt0LaunchBannerDismissed"; +import { dismissUsdt0LaunchBanner } from "./handlers/dismissUsdt0LaunchBanner"; import { loadBackendSettings } from "./handlers/loadBackendSettings"; import { saveBlockaidOverrideState } from "./handlers/saveDebugOverride"; import { getBlockaidOverrideState } from "./handlers/getDebugOverride"; @@ -579,6 +581,16 @@ export const popupMessageListener = ( localStore, }); } + case SERVICE_TYPES.GET_USDT0_LAUNCH_BANNER_DISMISSED: { + return getUsdt0LaunchBannerDismissed({ + localStore, + }); + } + case SERVICE_TYPES.DISMISS_USDT0_LAUNCH_BANNER: { + return dismissUsdt0LaunchBanner({ + localStore, + }); + } case SERVICE_TYPES.GET_BLOCKAID_DEBUG_OVERRIDE: { return getBlockaidOverrideState({ localStore, diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index d69f3a7627..3bca199400 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -28,6 +28,7 @@ export const HIDDEN_ASSETS = "hiddenAssets"; export const HIDDEN_COLLECTIBLES = "hiddenCollectibles"; export const TEMPORARY_STORE_ID = "temporaryStore"; export const TEMPORARY_STORE_EXTRA_ID = "temporaryStoreExtra"; +export const USDT0_LAUNCH_BANNER_DISMISSED = "usdt0LaunchBannerDismissed"; export const OVERRIDDEN_BLOCKAID_RESPONSE_ID = "overriddenBlockaidResponse"; export const COLLECTIBLES_ID = "collectibles"; export const IS_OPEN_SIDEBAR_BY_DEFAULT_ID = "isOpenSidebarByDefault"; diff --git a/extension/src/popup/assets/logo-usdt0.png b/extension/src/popup/assets/logo-usdt0.png new file mode 100644 index 0000000000..b193faf698 Binary files /dev/null and b/extension/src/popup/assets/logo-usdt0.png differ diff --git a/extension/src/popup/assets/usdt0-arcs.svg b/extension/src/popup/assets/usdt0-arcs.svg new file mode 100644 index 0000000000..4ed670073f --- /dev/null +++ b/extension/src/popup/assets/usdt0-arcs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/extension/src/popup/assets/usdt0-lockup.svg b/extension/src/popup/assets/usdt0-lockup.svg new file mode 100644 index 0000000000..cc9591d197 --- /dev/null +++ b/extension/src/popup/assets/usdt0-lockup.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extension/src/popup/components/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index 66e8c311e4..e6f9a61bd6 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -27,6 +27,7 @@ import { signOut } from "popup/ducks/accountServices"; import { AccountHeaderModal } from "popup/components/account/AccountHeaderModal"; import { NetworkIcon } from "popup/components/manageNetwork/NetworkIcon"; import { NetworkDetails } from "@shared/constants/stellar"; +import { Usdt0LaunchBanner } from "popup/components/account/Usdt0LaunchBanner"; import { AccountTabs } from "popup/components/account/AccountTabs"; import { MaintenanceBanner } from "popup/components/MaintenanceBanner"; import { getNetworkDisplayName } from "./getNetworkDisplayName"; @@ -416,6 +417,7 @@ export const AccountHeader = ({ + {isBackgroundActive ? createPortal( void; +} + +export const Usdt0LaunchSheet = ({ onClose }: Usdt0LaunchSheetProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleSwapClick = () => { + openTab(USDT0_SWAP_URL); + }; + + const handleReceiveClick = () => { + onClose(); + navigateTo(ROUTES.viewPublicKey, navigate); + }; + + const handleTransferClick = () => { + openTab(USDT0_TRANSFER_URL); + }; + + return ( +
+
+
+ +
+
+ {t("USDT0")} +
+
+
+ {/* Same BackButton as the QR code screen's X, supplied as a real + + } + /> +
+
+
+
+ {t("USDT0 is now on Stellar")} +
+ {/* global.scss forces `p` color to inherit (!important), which + would defeat the muted gray — render as div like the rows */} + + {t("Access USDT liquidity on Stellar with USDT0.")} + +
+
+
+
+ +
+
+ + {t("Move across networks")} + + + {t("Transfer USDT0 between Stellar and supported networks.")} + +
+
+
+
+ +
+
+ + {t("1:1 backed, unified liquidity")} + + + {t("USDT0 is backed 1:1 by USDT.")} + +
+
+
+
+
+ + + +
+
+
+ ); +}; diff --git a/extension/src/popup/components/account/Usdt0LaunchBanner/Usdt0LaunchSheet/styles.scss b/extension/src/popup/components/account/Usdt0LaunchBanner/Usdt0LaunchSheet/styles.scss new file mode 100644 index 0000000000..c94a0676f1 --- /dev/null +++ b/extension/src/popup/components/account/Usdt0LaunchBanner/Usdt0LaunchSheet/styles.scss @@ -0,0 +1,236 @@ +@use "../../../../styles/utils.scss" as *; + +.Usdt0LaunchSheet { + position: relative; + height: 100%; + width: 100%; + background: var(--sds-clr-gray-01); + // Clip the oversized hero art horizontally, but keep a vertical scroll + // path so the footer CTAs stay reachable in short viewports (squat + // sidebar windows, high zoom) + overflow-x: hidden; + overflow-y: auto; + + &__background { + position: absolute; + top: 0; + left: 0; + right: 0; + height: pxToRem(429px); + pointer-events: none; + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + height: pxToRem(377px); + background: linear-gradient(180deg, #002d19 0%, #161616 100%); + } + + &__arcs { + position: absolute; + top: pxToRem(77px); + left: 50%; + transform: translateX(-50%); + width: pxToRem(395px); + height: pxToRem(180px); + max-width: none; + } + + &__fade { + position: absolute; + top: pxToRem(73px); + left: 0; + right: 0; + height: pxToRem(356px); + background: linear-gradient( + 180deg, + rgba(22, 23, 23, 0) 4.7%, + #151616 44.67% + ); + } + + &__overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + height: pxToRem(356px); + background: linear-gradient( + 180deg, + rgba(22, 23, 23, 0) 4.7%, + rgba(21, 22, 22, 0.78) 44.67% + ); + } + + &__lockup { + position: absolute; + top: pxToRem(135px); + left: 50%; + transform: translateX(-50%); + width: pxToRem(165px); + height: auto; + } + } + + &__content { + position: relative; + display: flex; + flex-direction: column; + // Fills the sheet but may grow past it, scrolling the root, when the + // viewport is shorter than the content + min-height: 100%; + padding-bottom: pxToRem(24px); + gap: pxToRem(32px); + } + + // Mirrors the View app header metrics (80px tall, 24px inset) so the X + // sits exactly where the QR code screen's does + &__header { + display: flex; + align-items: center; + min-height: pxToRem(80px); + padding: 0 pxToRem(24px); + } + + // The BackButton class supplies the geometry; a + +
+ !open && setIsSheetOpen(false)} + > + e.preventDefault()} + aria-describedby={undefined} + side="bottom" + className="Usdt0LaunchBanner__sheet" + > + + {t("USDT0 is now on Stellar")} + + setIsSheetOpen(false)} /> + + + + ); +}; diff --git a/extension/src/popup/components/account/Usdt0LaunchBanner/styles.scss b/extension/src/popup/components/account/Usdt0LaunchBanner/styles.scss new file mode 100644 index 0000000000..627d442e0d --- /dev/null +++ b/extension/src/popup/components/account/Usdt0LaunchBanner/styles.scss @@ -0,0 +1,107 @@ +@use "../../../styles/utils.scss" as *; + +.Usdt0LaunchBanner { + position: relative; + background: #002d19; + border-radius: pxToRem(12px); + // No horizontal offsets: this sits in `AccountHeader__account-info__details`, + // which spans the full content column, so the banner stretches to match the + // action tiles and tab strip. + margin-bottom: pxToRem(8px); + // Pulls the banner halfway into the action tiles' 24px bottom padding — + // that padding also sets the tiles -> tabs gap when the banner is + // dismissed, so it can't be reduced globally. + margin-top: pxToRem(-12px); + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(12px); + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.9; + } + + // The launch surface is a real