diff --git a/frontend/src/components/SignerManagement/SignerManagement.css b/frontend/src/components/SignerManagement/SignerManagement.css index 84cc5b1..2c69229 100644 --- a/frontend/src/components/SignerManagement/SignerManagement.css +++ b/frontend/src/components/SignerManagement/SignerManagement.css @@ -61,6 +61,21 @@ font-family: ui-monospace, "Cascadia Code", monospace; } +/* Identicon + address inline wrapper */ +.signer-td__address-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.signer-identicon { + flex-shrink: 0; + display: inline-flex; + border-radius: 4px; + overflow: hidden; + line-height: 0; +} + .address-full { display: none; } diff --git a/frontend/src/components/SignerManagement/SignerManagement.test.tsx b/frontend/src/components/SignerManagement/SignerManagement.test.tsx new file mode 100644 index 0000000..ea0943b --- /dev/null +++ b/frontend/src/components/SignerManagement/SignerManagement.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import SignerManagement from './SignerManagement' +import * as useSignersModule from '../../hooks/useSigners' +import type { SignerInfo } from '../../types' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ADDR_A = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN' +const ADDR_B = 'GBVVJJPJZ3GR4VZVKNL4EOXTMQBQUXOUMGJXHWLZAGNNPPLZEXFQBVF' + +function makeSigners(addresses: string[]): SignerInfo[] { + return addresses.map((address, i) => ({ address, weight: i + 1 })) +} + +function mockUseSigners(signers: SignerInfo[], overrides: Partial> = {}) { + vi.spyOn(useSignersModule, 'useSigners').mockReturnValue({ + signers, + loading: false, + error: null, + addSigner: vi.fn(), + removeSigner: vi.fn(), + rotateSigners: vi.fn(), + refresh: vi.fn(), + ...overrides, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SignerManagement — identicons (issue #231)', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('renders an identicon for each signer in the list', () => { + mockUseSigners(makeSigners([ADDR_A, ADDR_B])) + render() + + const identicons = screen.getAllByRole('img', { name: /Identicon for/ }) + expect(identicons).toHaveLength(2) + }) + + it('renders one identicon per unique address', () => { + mockUseSigners(makeSigners([ADDR_A])) + render() + + expect(screen.getAllByRole('img', { name: /Identicon for/ })).toHaveLength(1) + }) + + it('each identicon has an accessible aria-label containing the shortened address', () => { + mockUseSigners(makeSigners([ADDR_A])) + render() + + // GAAZI4... shortened → "GAAZI4...CCWN" + const identicon = screen.getByRole('img', { name: /Identicon for GAAZI4/ }) + expect(identicon).toBeInTheDocument() + }) + + it('renders no identicons when signers list is empty', () => { + mockUseSigners([]) + render() + + expect(screen.queryAllByRole('img', { name: /Identicon for/ })).toHaveLength(0) + expect(screen.getByText('No signers configured.')).toBeInTheDocument() + }) + + it('identicons are distinct SVGs for different addresses', () => { + mockUseSigners(makeSigners([ADDR_A, ADDR_B])) + render() + + const identicons = screen.getAllByRole('img', { name: /Identicon for/ }) + expect(identicons).toHaveLength(2) + + // The inner SVG HTML should differ between the two addresses + const svg1 = identicons[0].innerHTML + const svg2 = identicons[1].innerHTML + expect(svg1).not.toBe(svg2) + }) + + it('identicon is deterministic: same address always yields same SVG', () => { + mockUseSigners(makeSigners([ADDR_A])) + const { unmount } = render() + const svg1 = screen.getByRole('img', { name: /Identicon for/ }).innerHTML + + unmount() + vi.restoreAllMocks() + mockUseSigners(makeSigners([ADDR_A])) + render() + const svg2 = screen.getByRole('img', { name: /Identicon for/ }).innerHTML + + expect(svg1).toBe(svg2) + }) +}) diff --git a/frontend/src/components/SignerManagement/SignerManagement.tsx b/frontend/src/components/SignerManagement/SignerManagement.tsx index 54acbd1..9e204b3 100644 --- a/frontend/src/components/SignerManagement/SignerManagement.tsx +++ b/frontend/src/components/SignerManagement/SignerManagement.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useSigners } from '../../hooks/useSigners' import { SignerInfo } from '../../types' +import { generateIdenticon } from '../../utils/identicon' import './SignerManagement.css' const STELLAR_ADDRESS_RE = /^[G][A-Z0-9]{55}$/ @@ -10,6 +11,22 @@ function shorten(addr: string): string { return `${addr.slice(0, 6)}...${addr.slice(-4)}` } +/** + * Renders a deterministic identicon for the given signer address. + * The SVG is generated entirely client-side with no network requests. + */ +function Identicon({ address, size = 32 }: { address: string; size?: number }) { + const svg = generateIdenticon(address, { size }) + return ( + + ) +} + function ConfirmModal({ title, message, @@ -222,8 +239,11 @@ function SignerRow({ return ( - {signer.address} - {shorten(signer.address)} + + + {signer.address} + {shorten(signer.address)} + {signer.weight} diff --git a/frontend/src/utils/identicon.ts b/frontend/src/utils/identicon.ts new file mode 100644 index 0000000..dd7014d --- /dev/null +++ b/frontend/src/utils/identicon.ts @@ -0,0 +1,106 @@ +/** + * Deterministic identicon generator. + * + * Produces a reproducible 5×5 pixel-grid SVG identicon from any string + * (typically a Stellar address). The output is purely computed from the + * input — no network requests, no external services. + * + * Algorithm: + * 1. Compute a simple 32-bit FNV-1a hash of the input string. + * 2. Derive foreground colour from the first 3 bytes of the hash. + * 3. Fill a 5×5 grid where each cell is "on" based on successive bits of + * the remaining hash bytes. The grid is mirrored horizontally so that + * the pattern is symmetric and recognisable (like GitHub identicons). + */ + +/** FNV-1a 32-bit hash — fast, deterministic, no crypto required. */ +function fnv1a32(str: string): number { + let hash = 2166136261 // FNV offset basis + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i) + // Multiply by FNV prime (32-bit, allow overflow via unsigned shift) + hash = (Math.imul(hash, 16777619) >>> 0) + } + return hash >>> 0 +} + +/** + * Generates an array of 32-bit hashes from the input string to provide + * enough bits for an arbitrary-length grid. + */ +function hashChain(str: string, count: number): number[] { + const chain: number[] = [] + let seed = fnv1a32(str) + for (let i = 0; i < count; i++) { + seed = fnv1a32(String(seed) + String(i) + str) + chain.push(seed) + } + return chain +} + +export interface IdenticonOptions { + /** Side length in pixels (default: 40). */ + size?: number + /** Background fill (default: '#f0f0f0'). */ + background?: string +} + +/** + * Returns an SVG string representing the identicon for the given address. + * + * @param address The signer address (or any string) to generate from. + * @param options Optional size / background overrides. + */ +export function generateIdenticon( + address: string, + options: IdenticonOptions = {}, +): string { + const size = options.size ?? 40 + const background = options.background ?? '#f0f0f0' + + const GRID = 5 // 5×5 cell grid (horizontally mirrored → 3 unique columns) + const COLS = Math.ceil(GRID / 2) // 3 unique columns, mirrored to 5 + + // Derive a stable colour from the address + const colorHash = fnv1a32(address) + const r = (colorHash >> 16) & 0xff + const g = (colorHash >> 8) & 0xff + const b = colorHash & 0xff + // Boost saturation by shifting components away from mid-grey + const boost = (v: number) => Math.round(v * 0.6 + 40) + const fg = `rgb(${boost(r)},${boost(g)},${boost(b)})` + + // Derive cell fill pattern (5×3 unique cells = 15 bits, one hash is enough) + const patternHash = hashChain(address, 1)[0] + const cellSize = size / GRID + + const rects: string[] = [] + + for (let row = 0; row < GRID; row++) { + for (let col = 0; col < COLS; col++) { + const bitIndex = row * COLS + col + const filled = (patternHash >> bitIndex) & 1 + + if (!filled) continue + + // Left side + const x1 = col * cellSize + const y = row * cellSize + rects.push(``) + + // Mirror to right side (skip centre column self-mirror) + const mirrorCol = GRID - 1 - col + if (mirrorCol !== col) { + const x2 = mirrorCol * cellSize + rects.push(``) + } + } + } + + return [ + ``, + ` `, + ...rects, + ``, + ].join('\n') +}