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 (
-
- {name.charAt(0).toUpperCase()}
-
- );
-}
+
/**
* 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 (
+
+ {initial}
+
+ );
+}
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,