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(' ') // → ""