From fb32b4b163b3a01c987318f57d98ffb194a947af Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Fri, 4 Sep 2026 05:58:30 +0000 Subject: [PATCH] feat(portal-framework-auth): deterministic wallet logos with validated fallbacks - add a curated wallet brand map keyed by EIP-6963 rdns (EVM) and the solana probe ids, with brand-colour monogram discs + an inline-SVG mark where one is archived (CoinbaseC) - new WalletLogo component resolves: curated SVM -> validated announced icon -> curated monogram -> neutral disc, replacing the private WalletGlyph; fixes solana rows that previously had no branding - sanitizeWalletIcon validates the EIP-6963 info.icon before render: base64 raster data-uri only, 32KiB cap, rejects svg (xss) and remote urls (unvetted fetch) - unit + browser coverage for the map, validation, and resolution chain --- .../common/WalletLogin.browser.spec.tsx | 11 ++- .../src/ui/components/common/WalletLogin.tsx | 39 +------- .../common/WalletLogo.browser.spec.tsx | 77 +++++++++++++++ .../src/ui/components/common/WalletLogo.tsx | 79 +++++++++++++++ .../common/walletLogos/CoinbaseC.tsx | 30 ++++++ .../src/wallet/logos.spec.ts | 81 ++++++++++++++++ .../portal-framework-auth/src/wallet/logos.ts | 96 +++++++++++++++++++ 7 files changed, 375 insertions(+), 38 deletions(-) create mode 100644 libs/portal-framework-auth/src/ui/components/common/WalletLogo.browser.spec.tsx create mode 100644 libs/portal-framework-auth/src/ui/components/common/WalletLogo.tsx create mode 100644 libs/portal-framework-auth/src/ui/components/common/walletLogos/CoinbaseC.tsx create mode 100644 libs/portal-framework-auth/src/wallet/logos.spec.ts create mode 100644 libs/portal-framework-auth/src/wallet/logos.ts diff --git a/libs/portal-framework-auth/src/ui/components/common/WalletLogin.browser.spec.tsx b/libs/portal-framework-auth/src/ui/components/common/WalletLogin.browser.spec.tsx index c8451f157..98835a08d 100644 --- a/libs/portal-framework-auth/src/ui/components/common/WalletLogin.browser.spec.tsx +++ b/libs/portal-framework-auth/src/ui/components/common/WalletLogin.browser.spec.tsx @@ -26,8 +26,12 @@ vi.mock("@/hooks/useWalletLogin", () => ({ }), })); +// A valid small base64 png so `sanitizeWalletIcon` accepts it on the row. +const VALID_PNG_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const META_MASK: DetectedWallet = { - icon: "data:image/png;base64,metamask-icon", + icon: VALID_PNG_URI, id: "io.metamask", name: "MetaMask", network: "ethereum", @@ -92,12 +96,13 @@ describe("WalletLogin picker", () => { .element(page.getByRole("button", { name: "Continue with Phantom" })) .toBeInTheDocument(); - // Announced icon URI renders as an img on the row. + // Announced icon URI renders as an img on the row (the wallet's own + // icon wins for a known id that has no curated SVG mark). const metaMaskRow = page .getByRole("button", { name: "Continue with MetaMask" }) .element() as HTMLElement; const icon = metaMaskRow.querySelector("img"); - expect(icon?.getAttribute("src")).toBe("data:image/png;base64,metamask-icon"); + expect(icon?.getAttribute("src")).toBe(VALID_PNG_URI); // Solana row without an icon → initial fallback chip instead of img. const phantomRow = page diff --git a/libs/portal-framework-auth/src/ui/components/common/WalletLogin.tsx b/libs/portal-framework-auth/src/ui/components/common/WalletLogin.tsx index ecc0c9d81..6240c3a92 100644 --- a/libs/portal-framework-auth/src/ui/components/common/WalletLogin.tsx +++ b/libs/portal-framework-auth/src/ui/components/common/WalletLogin.tsx @@ -11,6 +11,8 @@ import React, { useState } from "react"; import { useWalletLogin } from "@/hooks/useWalletLogin"; import { type DetectedWallet, detectWallets } from "@/wallet/detect"; +import { WalletLogo } from "./WalletLogo"; + const WalletIcon = lazyIcon("Wallet"); const TRIGGER_LABEL = "Continue with wallet"; @@ -118,40 +120,7 @@ export default function WalletLogin() { ); } -/** - * Leading icon chip: the announced wallet icon (data URI/URL) when available, - * otherwise the generic wallet glyph — matching AuthProviders' chip sizing. - */ -function WalletGlyph({ - icon, - name, - network, -}: { - icon?: string; - name: string; - network: DetectedWallet["network"]; -}) { - if (icon) { - return ( - - ); - } - // Unknown wallets keep a neutral disc with the wallet's initial. - return ( - - ); -} + /** * One labeled group of detected wallets (network header omitted when the @@ -176,7 +145,7 @@ function WalletGroup({ key={`${wallet.network}:${wallet.id}`} onClick={() => onPick(wallet)} variant="outline"> - + Continue with {wallet.name} ))} diff --git a/libs/portal-framework-auth/src/ui/components/common/WalletLogo.browser.spec.tsx b/libs/portal-framework-auth/src/ui/components/common/WalletLogo.browser.spec.tsx new file mode 100644 index 000000000..3411cbcf8 --- /dev/null +++ b/libs/portal-framework-auth/src/ui/components/common/WalletLogo.browser.spec.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; + +import type { DetectedWallet } from "@/wallet/detect"; + +import { WalletLogo } from "./WalletLogo"; + +const VALID_PNG_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function wallet(over: Partial): DetectedWallet { + return { + icon: undefined, + id: "io.metamask", + name: "MetaMask", + network: "ethereum", + provider: {}, + ...over, + }; +} + +// Each case awaits visibility first (render commits async), then inspects. +describe("WalletLogo resolution", () => { + it("renders a curated inline-SVG mark when the known id has an Icon", async () => { + render( + , + ); + const vector = page.getByTestId("wallet-logo-vector"); + await expect.element(vector).toBeInTheDocument(); + const svg = vector.element().querySelector("svg"); + expect(svg?.innerHTML).toContain("#0052FF"); + }); + + it("prefers a validated announced icon over the curated monogram for known ids", async () => { + render(); + const icon = page.getByTestId("wallet-logo-icon"); + await expect.element(icon).toBeInTheDocument(); + expect(icon.element().getAttribute("src")).toBe(VALID_PNG_URI); + expect(page.getByTestId("wallet-logo-monogram").query()).toBeNull(); + }); + + it("uses the curated brand-colour monogram when a known id has no SVG and no valid icon", async () => { + render(); + const mono = page.getByTestId("wallet-logo-monogram"); + await expect.element(mono).toBeInTheDocument(); + expect(mono.element().textContent).toBe("M"); + expect(mono.element().className).toContain("bg-[#F6851B]"); + expect(page.getByTestId("wallet-logo-icon").query()).toBeNull(); + }); + + it("rejects an unvalidated announced icon (svg) and falls back to the curated monogram", async () => { + render( + " })} + />, + ); + const mono = page.getByTestId("wallet-logo-monogram"); + await expect.element(mono).toBeInTheDocument(); + expect(page.getByTestId("wallet-logo-icon").query()).toBeNull(); + expect(mono.element().textContent).toBe("M"); + }); + + it("uses a neutral grey disc for unknown wallets", async () => { + render( + , + ); + const mono = page.getByTestId("wallet-logo-monogram"); + await expect.element(mono).toBeInTheDocument(); + expect(mono.element().textContent).toBe("M"); + expect(mono.element().className).toContain("bg-gray-500"); + }); +}); diff --git a/libs/portal-framework-auth/src/ui/components/common/WalletLogo.tsx b/libs/portal-framework-auth/src/ui/components/common/WalletLogo.tsx new file mode 100644 index 000000000..f9bda26d4 --- /dev/null +++ b/libs/portal-framework-auth/src/ui/components/common/WalletLogo.tsx @@ -0,0 +1,79 @@ +import { cn } from "@lumeweb/portal-framework-ui-core"; +import React from "react"; + +import { type DetectedWallet } from "@/wallet/detect"; +import { sanitizeWalletIcon, walletLogos } from "@/wallet/logos"; + +/** + * Deterministic logo treatment for a detected wallet. Resolution order: + * + * 1. curated inline-SVG brand mark (`walletLogos[id].Icon`) + * 2. the wallet's EIP-6963 `info.icon`, only once `sanitizeWalletIcon` + * accepts it (base64 raster data-URI, size-capped, no SVG/remote) + * 3. the curated brand-colour monogram disc (`walletLogos[id].color`) + * 4. a neutral grey disc with the wallet's initial + * + * The validated announced icon (2) beats the curated monogram (3): a known + * wallet that announces its own icon still shows it. The curated colour only + * steps in when no valid icon exists. + * + * Standalone component (sibling of `AuthProviders`) so the wallet and social + * rows share the same look and this stays testable in isolation. + */ +export function WalletLogo({ + className, + wallet, +}: { + className?: string; + wallet: Pick; +}) { + const entry = walletLogos[wallet.id]; + const Icon = entry?.Icon; + const safeIcon = sanitizeWalletIcon(wallet.icon); + const initial = wallet.name.charAt(0).toUpperCase(); + + if (Icon) { + return ( + + ); + } + + if (safeIcon) { + return ( + + ); + } + + const discClass = + entry?.color ?? + (wallet.network === "solana" ? "bg-purple-500" : "bg-gray-500"); + return ( + + ); +} diff --git a/libs/portal-framework-auth/src/ui/components/common/walletLogos/CoinbaseC.tsx b/libs/portal-framework-auth/src/ui/components/common/walletLogos/CoinbaseC.tsx new file mode 100644 index 000000000..b4f769eaa --- /dev/null +++ b/libs/portal-framework-auth/src/ui/components/common/walletLogos/CoinbaseC.tsx @@ -0,0 +1,30 @@ +import type { SVGAttributes } from "react"; + +/** + * Coinbase Wallet app icon (inline SVG) — vendored verbatim from the public + * Coinbase Wallet vector (`#0052FF` tile + the official even-odd white mark), + * matching the vendored-icon convention used by the social provider icons + * (see `providerIcons/`). + * + * Geometry source: public Coinbase Wallet SVG — https://gist.github.com/taycaldwell/2291907115c0bb5589bc346661435007 + */ +export type CoinbaseCProps = SVGAttributes; + +export const CoinbaseC = ({ className, ...rest }: CoinbaseCProps) => ( + +); diff --git a/libs/portal-framework-auth/src/wallet/logos.spec.ts b/libs/portal-framework-auth/src/wallet/logos.spec.ts new file mode 100644 index 000000000..659a5dd15 --- /dev/null +++ b/libs/portal-framework-auth/src/wallet/logos.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeWalletIcon, walletLogos } from "./logos"; + +describe("walletLogos", () => { + it("covers the Solana probe ids (always unbranded via announced icons)", () => { + for (const id of ["phantom", "solflare", "backpack"]) { + expect(walletLogos[id]).toBeDefined(); + } + }); + + it("covers common EVM EIP-6963 rdns ids", () => { + for (const id of [ + "io.metamask", + "com.coinbase.wallet", + "com.trustwallet.app", + "app.phantom", + ]) { + expect(walletLogos[id]).toBeDefined(); + } + }); + + it("omits unverified brand colours so they fall back to the neutral disc", () => { + expect(walletLogos["io.rabby"]).toBeUndefined(); + }); + + it("every entry has a fallback colour and only optional SVG icons", () => { + for (const [id, entry] of Object.entries(walletLogos)) { + expect(`${id}.color`).toBeTruthy(); + expect(entry.color).toBeTruthy(); + expect(entry.Icon === undefined || typeof entry.Icon === "function").toBe( + true, + ); + } + }); +}); + +describe("sanitizeWalletIcon", () => { + const PNGB64 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + it("accepts a small base64 png data URI", () => { + expect(sanitizeWalletIcon(PNGB64)).toBe(PNGB64); + }); + + it("accepts jpeg/webp/gif/avif data URIs", () => { + for (const mime of ["jpeg", "webp", "gif", "avif"]) { + expect(sanitizeWalletIcon(`data:image/${mime};base64,AAAA`)).toBeTruthy(); + } + }); + + it("rejects svg data URIs (scripting / XSS)", () => { + const svg = + "data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+PC9zdmc+"; + expect(sanitizeWalletIcon(svg)).toBeNull(); + // Non-base64 encoded svg also rejected. + expect( + sanitizeWalletIcon("data:image/svg+xml,"), + ).toBeNull(); + }); + + it("rejects remote http(s) URLs (privacy / unvetted fetch)", () => { + expect( + sanitizeWalletIcon("https://wallet.example/icon.png"), + ).toBeNull(); + expect(sanitizeWalletIcon("http://localhost/icon.png")).toBeNull(); + }); + + it("rejects non-image and malformed data URIs", () => { + expect(sanitizeWalletIcon("data:text/html;base64,PGI+")).toBeNull(); + expect(sanitizeWalletIcon("data:image/png;base64,not!base64!")).toBeNull(); + expect(sanitizeWalletIcon(undefined)).toBeNull(); + expect(sanitizeWalletIcon("")).toBeNull(); + }); + + it("rejects oversized blobs over the 32 KiB cap", () => { + const big = `data:image/png;base64,${"A".repeat(60_000)}`; + expect(big.length).toBeGreaterThan(32 * 1024); + expect(sanitizeWalletIcon(big)).toBeNull(); + }); +}); diff --git a/libs/portal-framework-auth/src/wallet/logos.ts b/libs/portal-framework-auth/src/wallet/logos.ts new file mode 100644 index 000000000..215845d9f --- /dev/null +++ b/libs/portal-framework-auth/src/wallet/logos.ts @@ -0,0 +1,96 @@ +import type { ComponentType, SVGAttributes } from "react"; + +import { CoinbaseC } from "@/ui/components/common/walletLogos/CoinbaseC"; + +export type WalletIconComponent = ComponentType>; + +/** + * Curated branding for known wallets, keyed by the id the detector emits. + * + * EVM ids are the wallet's EIP-6963 `rdns` (also the wagmi injected-connector + * id, so the key is stable across the connect path). Solana ids are the probe + * keys in `detectSolanaWallets` ("phantom", "solflare", "backpack"). This map + * is the primary source for a wallet row's look; the wallet's self-reported + * `info.icon` is a secondary, validated fallback (see `sanitizeWalletIcon`), + * and a neutral monogram is last. + * + * Unlike the social provider map there's no generated source: wallets live in + * the user's browser, not in backend meta, so entries are hand-written. A + * known wallet carries an `Icon` when an accurate inline-SVG mark exists, + * otherwise a brand-colour monogram disc with its initial. + */ +export interface WalletLogoEntry { + /** Tailwind background class for the monogram disc when `Icon` is absent. */ + color: string; + /** Accurate inline-SVG brand mark; when present it replaces the monogram. */ + Icon?: WalletIconComponent; +} + +const META_AMBER = "bg-[#F6851B]"; +const COINBASE_BLUE = "bg-[#0052FF]"; +const TRUST_BLUE = "bg-[#3375BB]"; +const RAINBOW_VIOLET = "bg-[#6B5BEA]"; +const PHANTOM_PURPLE = "bg-[#AB9FF2]"; +const OKX_BLACK = "bg-neutral-900"; +const ZERION_CYAN = "bg-[#12a5fd]"; +const BRAVE_ORANGE = "bg-[#FB542B]"; +const EXODUS_SLATE = "bg-slate-600"; +const SOLFLARE_ORANGE = "bg-[#FE6600]"; +const BACKPACK_RED = "bg-[#E91E63]"; + +/** + * Known-wallet branding keyed by EIP-6963 rdns (EVM) or Solana probe id. + * Ids not listed here still resolve correctly via the announced-icon → + * neutral-monogram fallbacks. + * + * Colour audit (2026-09): a value is an official brand primary only where + * confirmed against public brand sources (MetaMask, Coinbase, Phantom, OKX). + * Entries marked "approx" are single-hue stand-ins for a multi-colour or + * rebranded mark — fine on a monogram disc + initial, a placeholder until + * the official SVG gets archived as an `Icon`. `io.rabby` is absent because + * its brand colour could not be confirmed, so it uses the neutral disc. + * `rdns` is self-attested (EIP-6963) and used for display only, never auth. + */ +export const walletLogos: Record = { + "app.phantom": { color: PHANTOM_PURPLE }, + backpack: { color: BACKPACK_RED }, // approx: multi-colour mark + "com.brave.wallet": { color: BRAVE_ORANGE }, + "com.coinbase.wallet": { color: COINBASE_BLUE, Icon: CoinbaseC }, + "com.exodus": { color: EXODUS_SLATE }, // approx: dark-blue/black "X" mark + "com.okex.wallet": { color: OKX_BLACK }, + "com.trustwallet.app": { color: TRUST_BLUE }, // approx: 2023 rebrand is two-tone blue/green + // EVM (EIP-6963 rdns) + "io.metamask": { color: META_AMBER }, + "io.zerion.wallet": { color: ZERION_CYAN }, // approx + "me.rainbow": { color: RAINBOW_VIOLET }, // approx: rainbow-gradient mark + // Solana (probe ids) + phantom: { color: PHANTOM_PURPLE }, + solflare: { color: SOLFLARE_ORANGE }, +}; + +/** + * Validates a wallet-provided icon URI (EIP-6963 `info.icon`) before it is + * rendered as an ``. + * + * Rejects anything that is not a small base64 raster data URI: + * - remote http(s) URLs — never render an unvetted network fetch target + * (privacy leak / SSRF-adjacent surface); + * - `image/svg+xml` — SVG can carry embedded scripts (XSS); + * - oversized blobs — a 32 KiB cap keeps the picker cheap and the row sane. + * + * Returns the icon URI when acceptable, otherwise `null` (callers fall back + * to the curated monogram). + */ +const MAX_ICON_BYTES = 32 * 1024; +const ALLOWED_DATA_URI = + /^data:image\/(?:png|jpeg|webp|gif|avif);base64,[A-Za-z0-9+/=\s]+$/; + +export function sanitizeWalletIcon(icon: string | undefined): null | string { + if (!icon || !icon.startsWith("data:image/") || !ALLOWED_DATA_URI.test(icon)) { + return null; + } + const comma = icon.indexOf(","); + const bodyLen = icon.length - comma - 1; + const approxBytes = Math.floor((bodyLen * 3) / 4); + return approxBytes > MAX_ICON_BYTES ? null : icon; +}