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
15 changes: 15 additions & 0 deletions frontend/src/components/SignerManagement/SignerManagement.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/components/SignerManagement/SignerManagement.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useSignersModule.useSigners>> = {}) {
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(<SignerManagement />)

const identicons = screen.getAllByRole('img', { name: /Identicon for/ })
expect(identicons).toHaveLength(2)
})

it('renders one identicon per unique address', () => {
mockUseSigners(makeSigners([ADDR_A]))
render(<SignerManagement />)

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(<SignerManagement />)

// 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(<SignerManagement />)

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(<SignerManagement />)

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(<SignerManagement />)
const svg1 = screen.getByRole('img', { name: /Identicon for/ }).innerHTML

unmount()
vi.restoreAllMocks()
mockUseSigners(makeSigners([ADDR_A]))
render(<SignerManagement />)
const svg2 = screen.getByRole('img', { name: /Identicon for/ }).innerHTML

expect(svg1).toBe(svg2)
})
})
24 changes: 22 additions & 2 deletions frontend/src/components/SignerManagement/SignerManagement.tsx
Original file line number Diff line number Diff line change
@@ -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}$/
Expand All @@ -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 (
<span
className="signer-identicon"
aria-label={`Identicon for ${shorten(address)}`}
role="img"
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}

function ConfirmModal({
title,
message,
Expand Down Expand Up @@ -222,8 +239,11 @@ function SignerRow({
return (
<tr className="signer-row">
<td className="signer-td signer-td--address" title={signer.address}>
<span className="address-full">{signer.address}</span>
<span className="address-short">{shorten(signer.address)}</span>
<span className="signer-td__address-wrap">
<Identicon address={signer.address} size={32} />
<span className="address-full">{signer.address}</span>
<span className="address-short">{shorten(signer.address)}</span>
</span>
</td>
<td className="signer-td">
<span className="weight-badge">{signer.weight}</span>
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/utils/identicon.ts
Original file line number Diff line number Diff line change
@@ -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(`<rect x="${x1}" y="${y}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`)

// Mirror to right side (skip centre column self-mirror)
const mirrorCol = GRID - 1 - col
if (mirrorCol !== col) {
const x2 = mirrorCol * cellSize
rects.push(`<rect x="${x2}" y="${y}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`)
}
}
}

return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img">`,
` <rect width="${size}" height="${size}" fill="${background}"/>`,
...rects,
`</svg>`,
].join('\n')
}
Loading