-
+
+
+
+
+ {showAddressBook && (
+
+ )}
{(() => {
const isRisky = !canProceedWithRecipient(recipient, blockedWarning);
const validationMsg = getRecipientValidationMessage(recipient, blockedWarning);
diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js
index dc04b9f0..4bfb5035 100644
--- a/frontend/src/config/routes.js
+++ b/frontend/src/config/routes.js
@@ -73,6 +73,12 @@ export const ROUTE_ACTIVITY = '/activity';
*/
export const ROUTE_PROFILE = '/profile';
+/**
+ * Address book for managing saved addresses.
+ * @type {string}
+ */
+export const ROUTE_ADDRESS_BOOK = '/address-book';
+
/**
* Block/unblock addresses.
* @type {string}
@@ -119,6 +125,7 @@ export const ROUTE_LABELS = {
[ROUTE_LEADERBOARD]: 'Leaderboard',
[ROUTE_ACTIVITY]: 'My Activity',
[ROUTE_PROFILE]: 'Profile',
+ [ROUTE_ADDRESS_BOOK]: 'Address Book',
[ROUTE_BLOCK]: 'Block',
[ROUTE_STATS]: 'Stats',
[ROUTE_ADMIN]: 'Admin',
@@ -143,6 +150,7 @@ export const ROUTE_TITLES = {
[ROUTE_LEADERBOARD]: 'Leaderboard -- TipStream',
[ROUTE_ACTIVITY]: 'My Activity -- TipStream',
[ROUTE_PROFILE]: 'Profile -- TipStream',
+ [ROUTE_ADDRESS_BOOK]: 'Address Book -- TipStream',
[ROUTE_BLOCK]: 'Block Manager -- TipStream',
[ROUTE_STATS]: 'Platform Stats -- TipStream',
[ROUTE_ADMIN]: 'Admin Dashboard -- TipStream',
@@ -211,6 +219,11 @@ export const ROUTE_META = {
requiresAuth: true,
adminOnly: false,
},
+ [ROUTE_ADDRESS_BOOK]: {
+ description: 'Save and manage frequently used addresses.',
+ requiresAuth: true,
+ adminOnly: false,
+ },
[ROUTE_BLOCK]: {
description: 'Block or unblock specific Stacks addresses.',
requiresAuth: true,
diff --git a/frontend/src/lib/addressBook.js b/frontend/src/lib/addressBook.js
new file mode 100644
index 00000000..cbbd22b0
--- /dev/null
+++ b/frontend/src/lib/addressBook.js
@@ -0,0 +1,180 @@
+const STORAGE_KEY = 'tipstream_address_book';
+
+export class AddressBookEntry {
+ constructor({ id, label, address, notes = '', createdAt = Date.now(), updatedAt = Date.now() }) {
+ this.id = id || crypto.randomUUID();
+ this.label = label;
+ this.address = address;
+ this.notes = notes;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+
+ toJSON() {
+ return {
+ id: this.id,
+ label: this.label,
+ address: this.address,
+ notes: this.notes,
+ createdAt: this.createdAt,
+ updatedAt: this.updatedAt,
+ };
+ }
+}
+
+export class AddressBook {
+ constructor() {
+ this.entries = this.load();
+ }
+
+ load() {
+ try {
+ const data = localStorage.getItem(STORAGE_KEY);
+ if (!data) return [];
+ const parsed = JSON.parse(data);
+ return parsed.map(entry => new AddressBookEntry(entry));
+ } catch (error) {
+ console.error('Failed to load address book:', error);
+ return [];
+ }
+ }
+
+ save() {
+ try {
+ const data = JSON.stringify(this.entries.map(entry => entry.toJSON()));
+ localStorage.setItem(STORAGE_KEY, data);
+ return true;
+ } catch (error) {
+ console.error('Failed to save address book:', error);
+ return false;
+ }
+ }
+
+ add(label, address, notes = '') {
+ if (!label || !address) {
+ throw new Error('Label and address are required');
+ }
+
+ const sanitizedLabel = label.trim().slice(0, 50);
+ const sanitizedAddress = address.trim();
+ const sanitizedNotes = notes.trim().slice(0, 200);
+
+ if (this.findByAddress(sanitizedAddress)) {
+ throw new Error('Address already exists in address book');
+ }
+
+ const entry = new AddressBookEntry({
+ label: sanitizedLabel,
+ address: sanitizedAddress,
+ notes: sanitizedNotes
+ });
+ this.entries.push(entry);
+ this.save();
+ return entry;
+ }
+
+ update(id, updates) {
+ const index = this.entries.findIndex(entry => entry.id === id);
+ if (index === -1) {
+ throw new Error('Entry not found');
+ }
+
+ const entry = this.entries[index];
+ if (updates.label !== undefined) {
+ entry.label = updates.label.trim().slice(0, 50);
+ }
+ if (updates.address !== undefined) {
+ const sanitizedAddress = updates.address.trim();
+ const existing = this.findByAddress(sanitizedAddress);
+ if (existing && existing.id !== id) {
+ throw new Error('Address already exists in address book');
+ }
+ entry.address = sanitizedAddress;
+ }
+ if (updates.notes !== undefined) {
+ entry.notes = updates.notes.trim().slice(0, 200);
+ }
+ entry.updatedAt = Date.now();
+
+ this.save();
+ return entry;
+ }
+
+ delete(id) {
+ const index = this.entries.findIndex(entry => entry.id === id);
+ if (index === -1) {
+ throw new Error('Entry not found');
+ }
+
+ this.entries.splice(index, 1);
+ this.save();
+ return true;
+ }
+
+ findById(id) {
+ return this.entries.find(entry => entry.id === id);
+ }
+
+ findByAddress(address) {
+ return this.entries.find(entry => entry.address === address);
+ }
+
+ search(query) {
+ if (!query) return this.entries;
+
+ const lowerQuery = query.toLowerCase();
+ return this.entries.filter(entry =>
+ entry.label.toLowerCase().includes(lowerQuery) ||
+ entry.address.toLowerCase().includes(lowerQuery) ||
+ entry.notes.toLowerCase().includes(lowerQuery)
+ );
+ }
+
+ getAll() {
+ return [...this.entries];
+ }
+
+ exportData() {
+ return JSON.stringify(this.entries.map(entry => entry.toJSON()), null, 2);
+ }
+
+ importData(jsonData) {
+ try {
+ const parsed = JSON.parse(jsonData);
+ if (!Array.isArray(parsed)) {
+ throw new Error('Invalid data format');
+ }
+
+ const imported = [];
+ const skipped = [];
+
+ for (const item of parsed) {
+ if (!item.label || !item.address) {
+ skipped.push(item);
+ continue;
+ }
+
+ if (this.findByAddress(item.address)) {
+ skipped.push(item);
+ continue;
+ }
+
+ const entry = new AddressBookEntry(item);
+ this.entries.push(entry);
+ imported.push(entry);
+ }
+
+ this.save();
+ return { imported, skipped };
+ } catch (error) {
+ throw new Error(`Import failed: ${error.message}`);
+ }
+ }
+
+ clear() {
+ this.entries = [];
+ this.save();
+ }
+}
+
+export const addressBook = new AddressBook();
diff --git a/frontend/src/lib/addressValidation.js b/frontend/src/lib/addressValidation.js
new file mode 100644
index 00000000..3b5d6003
--- /dev/null
+++ b/frontend/src/lib/addressValidation.js
@@ -0,0 +1,42 @@
+const STACKS_ADDRESS_PATTERN = /^(SP|ST|SM)[0-9A-Z]{38,40}$/i;
+
+export function isValidStacksAddress(address) {
+ if (!address || typeof address !== 'string') {
+ return false;
+ }
+ return STACKS_ADDRESS_PATTERN.test(address.trim());
+}
+
+export function validateAddressBookEntry(label, address, notes = '') {
+ const errors = {};
+
+ if (!label || typeof label !== 'string') {
+ errors.label = 'Label is required';
+ } else if (label.trim().length === 0) {
+ errors.label = 'Label cannot be empty';
+ } else if (label.length > 50) {
+ errors.label = 'Label must be 50 characters or less';
+ }
+
+ if (!address || typeof address !== 'string') {
+ errors.address = 'Address is required';
+ } else if (!isValidStacksAddress(address)) {
+ errors.address = 'Invalid Stacks address format';
+ }
+
+ if (notes && typeof notes === 'string' && notes.length > 200) {
+ errors.notes = 'Notes must be 200 characters or less';
+ }
+
+ return {
+ isValid: Object.keys(errors).length === 0,
+ errors,
+ };
+}
+
+export function formatAddress(address, length = 8) {
+ if (!address || address.length <= length * 2) {
+ return address;
+ }
+ return `${address.slice(0, length)}...${address.slice(-length)}`;
+}
diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js
index 577e327c..5c8fba88 100644
--- a/frontend/src/lib/analytics.js
+++ b/frontend/src/lib/analytics.js
@@ -19,6 +19,13 @@ const DEFAULT_METRICS = {
scheduledTipsCancelled: 0,
scheduledTipsExecuted: 0,
scheduledTipsFailed: 0,
+ addressBookAdded: 0,
+ addressBookUpdated: 0,
+ addressBookDeleted: 0,
+ addressBookImported: 0,
+ addressBookExported: 0,
+ addressBookSearched: 0,
+ addressBookSelected: 0,
tabNavigations: {},
routeRedirects: {},
errors: {},
@@ -159,6 +166,38 @@ export const analytics = {
increment('scheduledTipsFailed');
},
+ trackAddressBookAdded() {
+ increment('addressBookAdded');
+ },
+
+ trackAddressBookUpdated() {
+ increment('addressBookUpdated');
+ },
+
+ trackAddressBookDeleted() {
+ increment('addressBookDeleted');
+ },
+
+ trackAddressBookImported(count) {
+ increment('addressBookImported');
+ const metrics = loadMetrics();
+ if (!metrics.addressBookImportCounts) metrics.addressBookImportCounts = {};
+ metrics.addressBookImportCounts[String(count)] = (metrics.addressBookImportCounts[String(count)] || 0) + 1;
+ saveMetrics(metrics);
+ },
+
+ trackAddressBookExported() {
+ increment('addressBookExported');
+ },
+
+ trackAddressBookSearched() {
+ increment('addressBookSearched');
+ },
+
+ trackAddressBookSelected() {
+ increment('addressBookSelected');
+ },
+
trackTabNavigation(tab) {
incrementMap('tabNavigations', tab);
},
@@ -230,6 +269,9 @@ export const analytics = {
const averageBatchSize = totalBatches > 0 ? (weightedSum / totalBatches).toFixed(1) : '0.0';
const sortedBatchSizes = batchSizeEntries.sort((a, b) => b[1] - a[1]);
+ const addressBookImportCounts = m.addressBookImportCounts || {};
+ const totalImports = Object.values(addressBookImportCounts).reduce((sum, count) => sum + count, 0);
+
return {
totalPageViews,
walletConnections: m.walletConnections,
@@ -247,6 +289,14 @@ export const analytics = {
scheduledTipsCancelled: m.scheduledTipsCancelled || 0,
scheduledTipsExecuted: m.scheduledTipsExecuted || 0,
scheduledTipsFailed: m.scheduledTipsFailed || 0,
+ addressBookAdded: m.addressBookAdded || 0,
+ addressBookUpdated: m.addressBookUpdated || 0,
+ addressBookDeleted: m.addressBookDeleted || 0,
+ addressBookImported: m.addressBookImported || 0,
+ addressBookExported: m.addressBookExported || 0,
+ addressBookSearched: m.addressBookSearched || 0,
+ addressBookSelected: m.addressBookSelected || 0,
+ totalImports,
batchCompletionRate,
batchDropOffRate,
averageBatchSize,
diff --git a/frontend/src/test/AddressBook.test.jsx b/frontend/src/test/AddressBook.test.jsx
new file mode 100644
index 00000000..772006d1
--- /dev/null
+++ b/frontend/src/test/AddressBook.test.jsx
@@ -0,0 +1,237 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import AddressBook from '../components/AddressBook';
+import { addressBook } from '../lib/addressBook';
+
+describe('AddressBook', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ addressBook.clear();
+ });
+
+ describe('rendering', () => {
+ it('renders address book heading', () => {
+ render(
);
+ expect(screen.getByText('Address Book')).toBeInTheDocument();
+ });
+
+ it('renders add address button', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /add address/i })).toBeInTheDocument();
+ });
+
+ it('renders import/export button', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /import\/export/i })).toBeInTheDocument();
+ });
+
+ it('shows empty state when no addresses', () => {
+ render(
);
+ expect(screen.getByText(/your address book is empty/i)).toBeInTheDocument();
+ });
+
+ it('displays saved addresses', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Friend');
+ render(
);
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ });
+
+ it('shows address count in footer', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ addressBook.add('Bob', 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE');
+ render(
);
+ expect(screen.getByText('2 addresses saved')).toBeInTheDocument();
+ });
+
+ it('uses singular form for single address', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ render(
);
+ expect(screen.getByText('1 address saved')).toBeInTheDocument();
+ });
+ });
+
+ describe('add address form', () => {
+ it('shows form when add button clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await user.click(screen.getByRole('button', { name: /add address/i }));
+
+ expect(screen.getByText('Add New Address')).toBeInTheDocument();
+ });
+
+ it('hides form when cancel button clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await user.click(screen.getByRole('button', { name: /add address/i }));
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ expect(screen.queryByText('Add New Address')).not.toBeInTheDocument();
+ });
+
+ it('changes button text to cancel when form is shown', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const button = screen.getByRole('button', { name: /add address/i });
+ await user.click(button);
+
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('search functionality', () => {
+ beforeEach(() => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Friend');
+ addressBook.add('Bob', 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE', 'Colleague');
+ addressBook.add('Charlie', 'SPZX6JZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ', 'Family');
+ });
+
+ it('filters addresses by label', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText(/search by label/i);
+ await user.type(searchInput, 'Alice');
+
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ expect(screen.queryByText('Bob')).not.toBeInTheDocument();
+ expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
+ });
+
+ it('filters addresses by notes', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText(/search by label/i);
+ await user.type(searchInput, 'Friend');
+
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ expect(screen.queryByText('Bob')).not.toBeInTheDocument();
+ });
+
+ it('shows no results message when search has no matches', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText(/search by label/i);
+ await user.type(searchInput, 'NonExistent');
+
+ expect(screen.getByText(/no addresses found matching/i)).toBeInTheDocument();
+ });
+
+ it('clears search when clear button clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const searchInput = screen.getByPlaceholderText(/search by label/i);
+ await user.type(searchInput, 'Alice');
+
+ const clearButton = screen.getByLabelText(/clear search/i);
+ await user.click(clearButton);
+
+ expect(searchInput).toHaveValue('');
+ expect(screen.getByText('Bob')).toBeInTheDocument();
+ expect(screen.getByText('Charlie')).toBeInTheDocument();
+ });
+ });
+
+ describe('delete functionality', () => {
+ it('deletes address when delete button clicked and confirmed', async () => {
+ const user = userEvent.setup();
+ window.confirm = vi.fn(() => true);
+
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ render(
);
+
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ await user.click(deleteButton);
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(screen.queryByText('Alice')).not.toBeInTheDocument();
+ expect(screen.getByText(/your address book is empty/i)).toBeInTheDocument();
+ });
+
+ it('does not delete when confirmation is cancelled', async () => {
+ const user = userEvent.setup();
+ window.confirm = vi.fn(() => false);
+
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ render(
);
+
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ await user.click(deleteButton);
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ });
+ });
+
+ describe('edit functionality', () => {
+ it('shows edit form when edit button clicked', async () => {
+ const user = userEvent.setup();
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ render(
);
+
+ const editButton = screen.getByRole('button', { name: /edit/i });
+ await user.click(editButton);
+
+ expect(screen.getByText('Edit Address')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('Alice')).toBeInTheDocument();
+ });
+ });
+
+ describe('compact mode', () => {
+ it('renders in compact mode', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ render(
);
+
+ expect(screen.queryByText('Address Book')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /add address/i })).not.toBeInTheDocument();
+ });
+
+ it('shows addresses as clickable items in compact mode', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ render(
);
+
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ });
+
+ it('calls onSelectAddress when address clicked in compact mode', async () => {
+ const user = userEvent.setup();
+ const onSelectAddress = vi.fn();
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ render(
);
+
+ const addressButton = screen.getByRole('button', { name: /alice/i });
+ await user.click(addressButton);
+
+ expect(onSelectAddress).toHaveBeenCalledWith('SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Alice');
+ });
+ });
+
+ describe('import/export', () => {
+ it('shows import/export panel when button clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await user.click(screen.getByRole('button', { name: /import\/export/i }));
+
+ expect(screen.getByText('Import/Export Address Book')).toBeInTheDocument();
+ });
+
+ it('hides import/export panel when close button clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await user.click(screen.getByRole('button', { name: /import\/export/i }));
+ const closeButton = screen.getByLabelText(/close/i);
+ await user.click(closeButton);
+
+ expect(screen.queryByText('Import/Export Address Book')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/test/addressBook.test.js b/frontend/src/test/addressBook.test.js
new file mode 100644
index 00000000..941b7f28
--- /dev/null
+++ b/frontend/src/test/addressBook.test.js
@@ -0,0 +1,328 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { AddressBook, AddressBookEntry } from '../lib/addressBook';
+
+describe('AddressBookEntry', () => {
+ it('creates entry with all fields', () => {
+ const entry = new AddressBookEntry({
+ label: 'Alice',
+ address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ notes: 'Test note',
+ });
+
+ expect(entry.label).toBe('Alice');
+ expect(entry.address).toBe('SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(entry.notes).toBe('Test note');
+ expect(entry.id).toBeDefined();
+ expect(entry.createdAt).toBeDefined();
+ expect(entry.updatedAt).toBeDefined();
+ });
+
+ it('generates unique IDs', () => {
+ const entry1 = new AddressBookEntry({
+ label: 'Alice',
+ address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ });
+ const entry2 = new AddressBookEntry({
+ label: 'Bob',
+ address: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE',
+ });
+
+ expect(entry1.id).not.toBe(entry2.id);
+ });
+
+ it('serializes to JSON correctly', () => {
+ const entry = new AddressBookEntry({
+ id: 'test-id',
+ label: 'Alice',
+ address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ notes: 'Test note',
+ createdAt: 1000,
+ updatedAt: 2000,
+ });
+
+ const json = entry.toJSON();
+ expect(json).toEqual({
+ id: 'test-id',
+ label: 'Alice',
+ address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ notes: 'Test note',
+ createdAt: 1000,
+ updatedAt: 2000,
+ });
+ });
+});
+
+describe('AddressBook', () => {
+ let addressBook;
+
+ beforeEach(() => {
+ localStorage.clear();
+ addressBook = new AddressBook();
+ });
+
+ describe('add', () => {
+ it('adds new entry', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Test note');
+
+ expect(entry.label).toBe('Alice');
+ expect(entry.address).toBe('SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(entry.notes).toBe('Test note');
+ expect(addressBook.getAll()).toHaveLength(1);
+ });
+
+ it('throws error for missing label', () => {
+ expect(() => {
+ addressBook.add('', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ }).toThrow('Label and address are required');
+ });
+
+ it('throws error for missing address', () => {
+ expect(() => {
+ addressBook.add('Alice', '');
+ }).toThrow('Label and address are required');
+ });
+
+ it('throws error for duplicate address', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ expect(() => {
+ addressBook.add('Bob', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ }).toThrow('Address already exists in address book');
+ });
+
+ it('persists to localStorage', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ const newAddressBook = new AddressBook();
+ expect(newAddressBook.getAll()).toHaveLength(1);
+ expect(newAddressBook.getAll()[0].label).toBe('Alice');
+ });
+ });
+
+ describe('update', () => {
+ it('updates entry label', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ addressBook.update(entry.id, { label: 'Alice Updated' });
+
+ const updated = addressBook.findById(entry.id);
+ expect(updated.label).toBe('Alice Updated');
+ });
+
+ it('updates entry address', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ addressBook.update(entry.id, { address: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE' });
+
+ const updated = addressBook.findById(entry.id);
+ expect(updated.address).toBe('SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE');
+ });
+
+ it('updates entry notes', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ addressBook.update(entry.id, { notes: 'Updated note' });
+
+ const updated = addressBook.findById(entry.id);
+ expect(updated.notes).toBe('Updated note');
+ });
+
+ it('updates timestamp', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ const originalTimestamp = entry.updatedAt;
+
+ addressBook.update(entry.id, { label: 'Alice Updated' });
+
+ const updated = addressBook.findById(entry.id);
+ expect(updated.updatedAt).toBeGreaterThan(originalTimestamp);
+ });
+
+ it('throws error for non-existent entry', () => {
+ expect(() => {
+ addressBook.update('non-existent-id', { label: 'Test' });
+ }).toThrow('Entry not found');
+ });
+
+ it('throws error when updating to duplicate address', () => {
+ const entry1 = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ addressBook.add('Bob', 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE');
+
+ expect(() => {
+ addressBook.update(entry1.id, { address: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE' });
+ }).toThrow('Address already exists in address book');
+ });
+ });
+
+ describe('delete', () => {
+ it('deletes entry', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ addressBook.delete(entry.id);
+
+ expect(addressBook.getAll()).toHaveLength(0);
+ expect(addressBook.findById(entry.id)).toBeUndefined();
+ });
+
+ it('throws error for non-existent entry', () => {
+ expect(() => {
+ addressBook.delete('non-existent-id');
+ }).toThrow('Entry not found');
+ });
+
+ it('persists deletion to localStorage', () => {
+ const entry = addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ addressBook.delete(entry.id);
+
+ const newAddressBook = new AddressBook();
+ expect(newAddressBook.getAll()).toHaveLength(0);
+ });
+ });
+
+ describe('search', () => {
+ beforeEach(() => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Friend');
+ addressBook.add('Bob', 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE', 'Colleague');
+ addressBook.add('Charlie', 'SPZX6JZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ', 'Family');
+ });
+
+ it('searches by label', () => {
+ const results = addressBook.search('Alice');
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('Alice');
+ });
+
+ it('searches by address', () => {
+ const results = addressBook.search('SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE');
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('Bob');
+ });
+
+ it('searches by notes', () => {
+ const results = addressBook.search('Friend');
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('Alice');
+ });
+
+ it('is case insensitive', () => {
+ const results = addressBook.search('alice');
+ expect(results).toHaveLength(1);
+ expect(results[0].label).toBe('Alice');
+ });
+
+ it('returns all entries for empty query', () => {
+ const results = addressBook.search('');
+ expect(results).toHaveLength(3);
+ });
+
+ it('returns empty array for no matches', () => {
+ const results = addressBook.search('NonExistent');
+ expect(results).toHaveLength(0);
+ });
+ });
+
+ describe('import/export', () => {
+ it('exports data as JSON', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Test');
+
+ const exported = addressBook.exportData();
+ const parsed = JSON.parse(exported);
+
+ expect(Array.isArray(parsed)).toBe(true);
+ expect(parsed).toHaveLength(1);
+ expect(parsed[0].label).toBe('Alice');
+ });
+
+ it('imports valid data', () => {
+ const data = JSON.stringify([
+ {
+ label: 'Alice',
+ address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ notes: 'Test',
+ },
+ {
+ label: 'Bob',
+ address: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE',
+ notes: 'Test 2',
+ },
+ ]);
+
+ const result = addressBook.importData(data);
+
+ expect(result.imported).toHaveLength(2);
+ expect(result.skipped).toHaveLength(0);
+ expect(addressBook.getAll()).toHaveLength(2);
+ });
+
+ it('skips duplicate addresses during import', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+
+ const data = JSON.stringify([
+ {
+ label: 'Alice Duplicate',
+ address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ },
+ {
+ label: 'Bob',
+ address: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE',
+ },
+ ]);
+
+ const result = addressBook.importData(data);
+
+ expect(result.imported).toHaveLength(1);
+ expect(result.skipped).toHaveLength(1);
+ expect(addressBook.getAll()).toHaveLength(2);
+ });
+
+ it('skips invalid entries during import', () => {
+ const data = JSON.stringify([
+ {
+ label: 'Alice',
+ },
+ {
+ address: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE',
+ },
+ {
+ label: 'Charlie',
+ address: 'SPZX6JZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ',
+ },
+ ]);
+
+ const result = addressBook.importData(data);
+
+ expect(result.imported).toHaveLength(1);
+ expect(result.skipped).toHaveLength(2);
+ });
+
+ it('throws error for invalid JSON', () => {
+ expect(() => {
+ addressBook.importData('invalid json');
+ }).toThrow('Import failed');
+ });
+
+ it('throws error for non-array data', () => {
+ expect(() => {
+ addressBook.importData('{"label":"Alice"}');
+ }).toThrow('Invalid data format');
+ });
+ });
+
+ describe('clear', () => {
+ it('removes all entries', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ addressBook.add('Bob', 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE');
+
+ addressBook.clear();
+
+ expect(addressBook.getAll()).toHaveLength(0);
+ });
+
+ it('persists clear to localStorage', () => {
+ addressBook.add('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ addressBook.clear();
+
+ const newAddressBook = new AddressBook();
+ expect(newAddressBook.getAll()).toHaveLength(0);
+ });
+ });
+});
diff --git a/frontend/src/test/addressBookValidation.test.js b/frontend/src/test/addressBookValidation.test.js
new file mode 100644
index 00000000..b96bd5d8
--- /dev/null
+++ b/frontend/src/test/addressBookValidation.test.js
@@ -0,0 +1,138 @@
+import { describe, it, expect } from 'vitest';
+import { validateAddressBookEntry, formatAddress } from '../lib/addressValidation';
+
+describe('validateAddressBookEntry', () => {
+ describe('label validation', () => {
+ it('accepts valid label', () => {
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(true);
+ expect(result.errors.label).toBeUndefined();
+ });
+
+ it('rejects empty label', () => {
+ const result = validateAddressBookEntry('', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(false);
+ expect(result.errors.label).toBe('Label is required');
+ });
+
+ it('rejects label exceeding 50 characters', () => {
+ const longLabel = 'A'.repeat(51);
+ const result = validateAddressBookEntry(longLabel, 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(false);
+ expect(result.errors.label).toBe('Label must be 50 characters or less');
+ });
+
+ it('accepts label with 50 characters', () => {
+ const maxLabel = 'A'.repeat(50);
+ const result = validateAddressBookEntry(maxLabel, 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(true);
+ expect(result.errors.label).toBeUndefined();
+ });
+ });
+
+ describe('address validation', () => {
+ it('accepts valid mainnet address', () => {
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(true);
+ expect(result.errors.address).toBeUndefined();
+ });
+
+ it('accepts valid testnet address', () => {
+ const result = validateAddressBookEntry('Alice', 'ST2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(true);
+ expect(result.errors.address).toBeUndefined();
+ });
+
+ it('rejects empty address', () => {
+ const result = validateAddressBookEntry('Alice', '');
+ expect(result.isValid).toBe(false);
+ expect(result.errors.address).toBe('Address is required');
+ });
+
+ it('rejects invalid address format', () => {
+ const result = validateAddressBookEntry('Alice', 'invalid-address');
+ expect(result.isValid).toBe(false);
+ expect(result.errors.address).toBe('Invalid Stacks address format');
+ });
+
+ it('rejects address with wrong prefix', () => {
+ const result = validateAddressBookEntry('Alice', 'XX2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7');
+ expect(result.isValid).toBe(false);
+ expect(result.errors.address).toBe('Invalid Stacks address format');
+ });
+
+ it('rejects address with wrong length', () => {
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ');
+ expect(result.isValid).toBe(false);
+ expect(result.errors.address).toBe('Invalid Stacks address format');
+ });
+ });
+
+ describe('notes validation', () => {
+ it('accepts valid notes', () => {
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', 'Test note');
+ expect(result.isValid).toBe(true);
+ expect(result.errors.notes).toBeUndefined();
+ });
+
+ it('accepts empty notes', () => {
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', '');
+ expect(result.isValid).toBe(true);
+ expect(result.errors.notes).toBeUndefined();
+ });
+
+ it('rejects notes exceeding 200 characters', () => {
+ const longNotes = 'A'.repeat(201);
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', longNotes);
+ expect(result.isValid).toBe(false);
+ expect(result.errors.notes).toBe('Notes must be 200 characters or less');
+ });
+
+ it('accepts notes with 200 characters', () => {
+ const maxNotes = 'A'.repeat(200);
+ const result = validateAddressBookEntry('Alice', 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', maxNotes);
+ expect(result.isValid).toBe(true);
+ expect(result.errors.notes).toBeUndefined();
+ });
+ });
+
+ describe('multiple errors', () => {
+ it('returns all validation errors', () => {
+ const result = validateAddressBookEntry('', 'invalid', 'A'.repeat(201));
+ expect(result.isValid).toBe(false);
+ expect(result.errors.label).toBeDefined();
+ expect(result.errors.address).toBeDefined();
+ expect(result.errors.notes).toBeDefined();
+ });
+ });
+});
+
+describe('formatAddress', () => {
+ const fullAddress = 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7';
+
+ it('returns full address by default', () => {
+ const result = formatAddress(fullAddress);
+ expect(result).toBe(fullAddress);
+ });
+
+ it('truncates address with specified length', () => {
+ const result = formatAddress(fullAddress, 10);
+ expect(result).toBe('SP2J6ZY48G...KNRV9EJ7');
+ });
+
+ it('returns full address if length is greater than address length', () => {
+ const result = formatAddress(fullAddress, 100);
+ expect(result).toBe(fullAddress);
+ });
+
+ it('handles short addresses', () => {
+ const shortAddress = 'SP123';
+ const result = formatAddress(shortAddress, 10);
+ expect(result).toBe(shortAddress);
+ });
+
+ it('handles minimum truncation length', () => {
+ const result = formatAddress(fullAddress, 8);
+ expect(result).toBe('SP2J6ZY4...RV9EJ7');
+ });
+});