diff --git a/docs/components.md b/docs/components.md index aba2c57..196e572 100644 --- a/docs/components.md +++ b/docs/components.md @@ -213,7 +213,7 @@ Source: [`src/components/AddressInput.tsx`](../src/components/AddressInput.tsx). | `disabled` | `boolean` | `false` | | `className` | `string` | `''` | -Accessibility: composes `FormField`, so label, hint, and error IDs wire through `htmlFor`, `aria-describedby`, and `aria-invalid`. Paste and copy controls are native buttons with explicit aria labels and hidden SVGs. Validation requires a 56-character Stellar public key starting with `G`; invalid feedback is exposed by the FormField alert. +Accessibility: composes `FormField`, so label, hint, and error IDs wire through `htmlFor`, `aria-describedby`, and `aria-invalid`. Paste and copy controls are native buttons with explicit aria labels and hidden SVGs. Validation now performs two checks: (1) a format check (56-character, starts with `G`, uppercase alphanumeric), and (2) a **CRC-16 XMODEM checksum** verify per the Stellar StrKey spec. Distinct error messages are surfaced for each failure mode — `"Invalid address. Stellar public keys are 56 characters starting with G."` for a format error, and `"Invalid address checksum. Please verify the address."` for a checksum mismatch — and both are exposed via `role="alert"` with `aria-invalid="true"` set on the `` when any error is active. Tokens: border, danger, primary, slate, success, focus, font, line-height, motion, radius, spacing, surface, and text tokens. diff --git a/src/components/AddressInput.stories.tsx b/src/components/AddressInput.stories.tsx index 979a62e..bd8533e 100644 --- a/src/components/AddressInput.stories.tsx +++ b/src/components/AddressInput.stories.tsx @@ -27,7 +27,7 @@ export const Default: Story = { export const Filled: Story = { args: { - value: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA', + value: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H', }, }; @@ -38,9 +38,17 @@ export const Invalid: Story = { }, }; -export const Disabled: Story = { +export const ChecksumError: Story = { args: { + // Format-valid but fails CRC-16 checksum – mirrors the real error state value: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA', + error: 'Invalid address checksum. Please verify the address.', + }, +}; + +export const Disabled: Story = { + args: { + value: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H', disabled: true, }, }; diff --git a/src/components/AddressInput.test.tsx b/src/components/AddressInput.test.tsx index 7f0296e..9f59460 100644 --- a/src/components/AddressInput.test.tsx +++ b/src/components/AddressInput.test.tsx @@ -3,8 +3,10 @@ import userEvent from '@testing-library/user-event' import { vi, describe, it, expect, beforeEach } from 'vitest' import AddressInput from './AddressInput' -// A valid 56-character Stellar public key -const VALID_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA' // 56 chars +// A valid 56-character Stellar public key (passes CRC-16 XMODEM checksum) +const VALID_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H' // 56 chars +// A 56-char G-prefixed uppercase alphanumeric key that fails the CRC-16 checksum +const INVALID_CHECKSUM_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA' // --- useDebouncedValue mocking --- @@ -139,6 +141,16 @@ describe('conditional rendering', () => { expect(screen.getByRole('alert')).toHaveTextContent('Invalid address') }) + it('shows checksum error after blur with format-valid but bad-checksum address', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('textbox')) + await user.tab() + const alert = screen.getByRole('alert') + expect(alert).toBeInTheDocument() + expect(alert).toHaveTextContent('checksum') + }) + it('does not show error when value is empty after blur', async () => { const user = userEvent.setup() render() diff --git a/src/components/AddressInput.tsx b/src/components/AddressInput.tsx index 1f64423..18a32ef 100644 --- a/src/components/AddressInput.tsx +++ b/src/components/AddressInput.tsx @@ -201,7 +201,8 @@ export default function AddressInput({ } }, [copy, value]) - const error = externalError || (showError ? 'Invalid address. Stellar public keys are 56 characters starting with G.' : undefined) + const isChecksumError = showError && /^G[A-Z0-9]{55}$/.test(debouncedValue) + const error = externalError || (showError ? (isChecksumError ? 'Invalid address checksum. Please verify the address.' : 'Invalid address. Stellar public keys are 56 characters starting with G.') : undefined) const hint = 'Stellar public key format (56 characters, starts with G)' // Detect whether the entered (validated) value matches the connected wallet's address. diff --git a/src/lib/stellar.test.ts b/src/lib/stellar.test.ts index 6307fb9..1fb7e37 100644 --- a/src/lib/stellar.test.ts +++ b/src/lib/stellar.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from 'vitest' import { isValidStellarAddress, truncateAddress } from './stellar' -// A valid 56-character Stellar public key -const VALID_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA' // 56 chars +// A valid 56-character Stellar public key (passes CRC-16 XMODEM checksum) +const VALID_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H' // 56 chars describe('isValidStellarAddress', () => { it('returns true for valid Stellar public keys', () => { expect(isValidStellarAddress(VALID_KEY)).toBe(true) - // Another valid key - expect(isValidStellarAddress('GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H')).toBe( - true + // Another valid key (also passes checksum) + expect(isValidStellarAddress('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA')).toBe( + false // this key fails the CRC-16 checksum ) }) @@ -41,6 +41,11 @@ describe('isValidStellarAddress', () => { ) // special char }) + it('returns false for a format-valid key that fails the CRC-16 checksum', () => { + // GAAZI4... is 56 chars, starts with G, uppercase alphanumeric, but fails checksum + expect(isValidStellarAddress('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA')).toBe(false) + }) + it('returns false for wrong prefix', () => { expect(isValidStellarAddress('TAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA')).toBe( false diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 65257b5..65decc4 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -7,21 +7,87 @@ */ /** - * Validates Stellar public key format. + * Validates Stellar public key format and CRC-16 checksum. * - * Valid addresses: 56 characters, starts with 'G', contains only - * uppercase letters A-Z and digits 0-9. + * Valid addresses: 56 characters, starts with 'G', contain only + * uppercase letters A-Z and digits 0-9, and pass the StrKey CRC-16 XMODEM checksum. * * @example - * isValidStellarAddress('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA') // → true + * isValidStellarAddress('GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H') // → true * isValidStellarAddress('invalid') // → false * isValidStellarAddress('') // → false * isValidStellarAddress(undefined) // → false */ +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + +function decodeBase32(input: string): Uint8Array | null { + const cleaned = input.toUpperCase().replace(/=+$/, '') + if (!/^[A-Z2-7]*$/.test(cleaned)) { + return null + } + + const length = cleaned.length + const bytes = new Uint8Array(Math.floor(length * 5 / 8)) + let bits = 0 + let value = 0 + let index = 0 + + for (let i = 0; i < length; i++) { + const char = cleaned[i] + const val = ALPHABET.indexOf(char) + if (val === -1) return null + + value = (value << 5) | val + bits += 5 + + if (bits >= 8) { + bytes[index++] = (value >>> (bits - 8)) & 255 + bits -= 8 + value &= (1 << bits) - 1 + } + } + + return bytes +} + +function calculateCRC16(data: Uint8Array): number { + const polynomial = 0x1021 + let crc = 0x0000 + + for (let i = 0; i < data.length; i++) { + crc ^= (data[i] << 8) + for (let j = 0; j < 8; j++) { + crc = (crc & 0x8000) ? ((crc << 1) ^ polynomial) : (crc << 1) + } + } + return crc & 0xFFFF +} + +function verifyChecksum(address: string): boolean { + if (address.length !== 56 || address[0] !== 'G') { + return false + } + const bytes = decodeBase32(address) + if (!bytes || bytes.length !== 35) { + return false + } + + const payload = bytes.slice(0, 33) + const checksumBytes = bytes.slice(33, 35) + const calculatedChecksum = calculateCRC16(payload) + + const expectedChecksum = checksumBytes[0] | (checksumBytes[1] << 8) + return calculatedChecksum === expectedChecksum +} + export function isValidStellarAddress(address: string | undefined | null): boolean { if (!address) return false - // Stellar addresses are 56 characters and start with 'G' - return /^G[A-Z0-9]{55}$/.test(address) + // Stellar addresses are 56 characters and start with 'G', followed by 55 uppercase alphanumeric chars + if (!/^G[A-Z0-9]{55}$/.test(address)) { + return false + } + // Additionally verify the StrKey CRC-16 XMODEM checksum + return verifyChecksum(address) } /** @@ -31,8 +97,8 @@ export function isValidStellarAddress(address: string | undefined | null): boole * Handles undefined/null values gracefully. Trims whitespace. * * @example - * truncateAddress('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA') - * // → "GAAZI4TCR3TY...CCWNA" + * truncateAddress('GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H') + * // → "GBRPYHIL2CI3...X2H" * truncateAddress('GABC') // → "GABC" * truncateAddress('') // → "" * truncateAddress(' ') // → ""