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
2 changes: 1 addition & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>` when any error is active.

Tokens: border, danger, primary, slate, success, focus, font, line-height, motion, radius, spacing, surface, and text tokens.

Expand Down
12 changes: 10 additions & 2 deletions src/components/AddressInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const Default: Story = {

export const Filled: Story = {
args: {
value: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA',
value: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H',
},
};

Expand All @@ -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,
},
};
Expand Down
16 changes: 14 additions & 2 deletions src/components/AddressInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -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(<AddressInput id="addr" value={INVALID_CHECKSUM_KEY} onChange={vi.fn()} />)
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(<AddressInput id="addr" value="" onChange={vi.fn()} />)
Expand Down
3 changes: 2 additions & 1 deletion src/components/AddressInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions src/lib/stellar.test.ts
Original file line number Diff line number Diff line change
@@ -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
)
})

Expand Down Expand Up @@ -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
Expand Down
82 changes: 74 additions & 8 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand All @@ -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(' ') // → ""
Expand Down