Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<img
alt=""
aria-hidden="true"
className="absolute left-3 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded object-contain top-1/2"
src={icon}
/>
);
}
// Unknown wallets keep a neutral disc with the wallet's initial.
return (
<span
aria-hidden="true"
className={`absolute left-3 flex h-7 w-7 items-center justify-center rounded-full text-white ${
network === "solana" ? "bg-purple-500" : "bg-gray-500"
}`}>
{name.charAt(0).toUpperCase()}
</span>
);
}


/**
* One labeled group of detected wallets (network header omitted when the
Expand All @@ -176,7 +145,7 @@ function WalletGroup({
key={`${wallet.network}:${wallet.id}`}
onClick={() => onPick(wallet)}
variant="outline">
<WalletGlyph icon={wallet.icon} name={wallet.name} network={wallet.network} />
<WalletLogo wallet={wallet} />
Continue with {wallet.name}
</Button>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>): 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(
<WalletLogo
wallet={wallet({ id: "com.coinbase.wallet", name: "Coinbase Wallet" })}
/>,
);
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(<WalletLogo wallet={wallet({ icon: VALID_PNG_URI })} />);
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(<WalletLogo wallet={wallet({ icon: undefined })} />);
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(
<WalletLogo
wallet={wallet({ icon: "data:image/svg+xml,<svg onload=alert(1)>" })}
/>,
);
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(
<WalletLogo
wallet={wallet({ id: "io.unknown-wallet", name: "Mystery" })}
/>,
);
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");
});
});
79 changes: 79 additions & 0 deletions libs/portal-framework-auth/src/ui/components/common/WalletLogo.tsx
Original file line number Diff line number Diff line change
@@ -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<DetectedWallet, "icon" | "id" | "name" | "network">;
}) {
const entry = walletLogos[wallet.id];
const Icon = entry?.Icon;
const safeIcon = sanitizeWalletIcon(wallet.icon);
const initial = wallet.name.charAt(0).toUpperCase();

if (Icon) {
return (
<span
aria-hidden="true"
className={cn(
"absolute left-3 flex h-7 w-7 -translate-y-1/2 items-center justify-center top-1/2",
className,
)}
data-testid="wallet-logo-vector">
<Icon className="h-7 w-7 rounded" />
</span>
);
}

if (safeIcon) {
return (
<img
alt=""
aria-hidden="true"
className={cn(
"absolute left-3 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded object-contain top-1/2",
className,
)}
data-testid="wallet-logo-icon"
src={safeIcon}
/>
);
}

const discClass =
entry?.color ??
(wallet.network === "solana" ? "bg-purple-500" : "bg-gray-500");
return (
<span
aria-hidden="true"
className={cn(
"absolute left-3 flex h-7 w-7 items-center justify-center rounded-full text-white",
discClass,
className,
)}
data-testid="wallet-logo-monogram">
{initial}
</span>
);
}
Original file line number Diff line number Diff line change
@@ -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<SVGSVGElement>;

export const CoinbaseC = ({ className, ...rest }: CoinbaseCProps) => (
<svg
aria-hidden="true"
className={className}
fill="none"
role="img"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
{...rest}>
<rect fill="#0052FF" height="1024" width="1024" />
<path
clipRule="evenodd"
d="M152 512C152 710.823 313.177 872 512 872C710.823 872 872 710.823 872 512C872 313.177 710.823 152 512 152C313.177 152 152 313.177 152 512ZM420 396C406.745 396 396 406.745 396 420V604C396 617.255 406.745 628 420 628H604C617.255 628 628 617.255 628 604V420C628 406.745 617.255 396 604 396H420Z"
fill="#fff"
fillRule="evenodd"
/>
</svg>
);
81 changes: 81 additions & 0 deletions libs/portal-framework-auth/src/wallet/logos.spec.ts
Original file line number Diff line number Diff line change
@@ -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,<svg onload=alert(1)>"),
).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();
});
});
Loading
Loading