diff --git a/packages/types/src/__tests__/user-operation.test.ts b/packages/types/src/__tests__/user-operation.test.ts new file mode 100644 index 0000000..9e27834 --- /dev/null +++ b/packages/types/src/__tests__/user-operation.test.ts @@ -0,0 +1,298 @@ +import { + UserOperationSchema, + TransactionResultSchema, +} from '../user-operation'; +import { + isUserOperation, + isTransactionResult, +} from '../guards'; + +describe('UserOperation', () => { + describe('UserOperationSchema', () => { + test('parses valid UserOperation', () => { + const op = { + id: 'op-123', + type: 'payment', + operation: { type: 'payment', amount: '100' }, + gasLimit: 1000, + createdAt: Date.now(), + }; + const parsed = UserOperationSchema.parse(op); + expect(parsed.id).toBe('op-123'); + expect(parsed.type).toBe('payment'); + expect(parsed.gasLimit).toBe(1000); + }); + + test('parses UserOperation without optional gasLimit', () => { + const op = { + id: 'op-456', + type: 'invoke', + operation: { type: 'invokeHostFunction' }, + createdAt: Date.now(), + }; + const parsed = UserOperationSchema.parse(op); + expect(parsed.id).toBe('op-456'); + expect(parsed.gasLimit).toBeUndefined(); + }); + + test('rejects empty id', () => { + const op = { + id: '', + type: 'payment', + operation: {}, + createdAt: Date.now(), + }; + expect(() => UserOperationSchema.parse(op)).toThrow(); + }); + + test('rejects negative createdAt', () => { + const op = { + id: 'op-789', + type: 'payment', + operation: {}, + createdAt: -1, + }; + expect(() => UserOperationSchema.parse(op)).toThrow(); + }); + + test('rejects negative gasLimit', () => { + const op = { + id: 'op-999', + type: 'payment', + operation: {}, + gasLimit: -100, + createdAt: Date.now(), + }; + expect(() => UserOperationSchema.parse(op)).toThrow(); + }); + }); + + describe('isUserOperation', () => { + test('returns true for valid UserOperation', () => { + const op = { + id: 'test-op', + type: 'payment', + operation: {}, + createdAt: Date.now(), + }; + expect(isUserOperation(op)).toBe(true); + }); + + test('returns true for UserOperation with gasLimit', () => { + const op = { + id: 'test-op', + type: 'payment', + operation: { type: 'payment' }, + gasLimit: 5000, + createdAt: Date.now(), + }; + expect(isUserOperation(op)).toBe(true); + }); + + test('returns false for null', () => { + expect(isUserOperation(null)).toBe(false); + }); + + test('returns false for missing id', () => { + expect( + isUserOperation({ + type: 'payment', + operation: {}, + createdAt: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for missing type', () => { + expect( + isUserOperation({ + id: 'test', + operation: {}, + createdAt: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for missing operation', () => { + expect( + isUserOperation({ + id: 'test', + type: 'payment', + createdAt: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for missing createdAt', () => { + expect( + isUserOperation({ + id: 'test', + type: 'payment', + operation: {}, + }) + ).toBe(false); + }); + + test('returns false for null operation', () => { + expect( + isUserOperation({ + id: 'test', + type: 'payment', + operation: null, + createdAt: Date.now(), + }) + ).toBe(false); + }); + }); +}); + +describe('TransactionResult', () => { + describe('TransactionResultSchema', () => { + test('parses successful TransactionResult', () => { + const result = { + status: 'success', + hash: 'txabcd1234', + ledger: 1000, + timestamp: Date.now(), + }; + const parsed = TransactionResultSchema.parse(result); + expect(parsed.status).toBe('success'); + expect(parsed.hash).toBe('txabcd1234'); + expect(parsed.ledger).toBe(1000); + }); + + test('parses failed TransactionResult with error', () => { + const result = { + status: 'failure', + error: 'Insufficient balance', + timestamp: Date.now(), + }; + const parsed = TransactionResultSchema.parse(result); + expect(parsed.status).toBe('failure'); + expect(parsed.error).toBe('Insufficient balance'); + }); + + test('parses pending TransactionResult', () => { + const result = { + status: 'pending', + timestamp: Date.now(), + }; + const parsed = TransactionResultSchema.parse(result); + expect(parsed.status).toBe('pending'); + }); + + test('rejects invalid status', () => { + const result = { + status: 'invalid', + timestamp: Date.now(), + }; + expect(() => TransactionResultSchema.parse(result)).toThrow(); + }); + + test('rejects negative ledger', () => { + const result = { + status: 'success', + ledger: -1, + timestamp: Date.now(), + }; + expect(() => TransactionResultSchema.parse(result)).toThrow(); + }); + + test('rejects negative timestamp', () => { + const result = { + status: 'success', + timestamp: -1, + }; + expect(() => TransactionResultSchema.parse(result)).toThrow(); + }); + }); + + describe('isTransactionResult', () => { + test('returns true for valid success result', () => { + const result = { + status: 'success', + hash: 'tx123', + ledger: 500, + timestamp: Date.now(), + }; + expect(isTransactionResult(result)).toBe(true); + }); + + test('returns true for valid failure result', () => { + const result = { + status: 'failure', + error: 'Transaction failed', + timestamp: Date.now(), + }; + expect(isTransactionResult(result)).toBe(true); + }); + + test('returns true for valid pending result', () => { + const result = { + status: 'pending', + timestamp: Date.now(), + }; + expect(isTransactionResult(result)).toBe(true); + }); + + test('returns false for null', () => { + expect(isTransactionResult(null)).toBe(false); + }); + + test('returns false for missing status', () => { + expect( + isTransactionResult({ + hash: 'tx123', + timestamp: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for missing timestamp', () => { + expect( + isTransactionResult({ + status: 'success', + hash: 'tx123', + }) + ).toBe(false); + }); + + test('returns false for invalid status', () => { + expect( + isTransactionResult({ + status: 'unknown', + timestamp: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for non-numeric ledger', () => { + expect( + isTransactionResult({ + status: 'success', + ledger: 'not-a-number', + timestamp: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for non-string hash', () => { + expect( + isTransactionResult({ + status: 'success', + hash: 12345, + timestamp: Date.now(), + }) + ).toBe(false); + }); + + test('returns false for non-numeric timestamp', () => { + expect( + isTransactionResult({ + status: 'success', + timestamp: 'not-a-number', + }) + ).toBe(false); + }); + }); +}); diff --git a/packages/types/src/__tests__/wallet.test.ts b/packages/types/src/__tests__/wallet.test.ts new file mode 100644 index 0000000..b0ff83d --- /dev/null +++ b/packages/types/src/__tests__/wallet.test.ts @@ -0,0 +1,126 @@ +import { WalletState, StorageKey, WalletStateSchema } from '../wallet'; +import { isWalletState } from '../guards'; + +describe('WalletState', () => { + describe('WalletStateSchema', () => { + test('parses uninitialized state', () => { + const state = 'uninitialized'; + const parsed = WalletStateSchema.parse(state); + expect(parsed).toBe('uninitialized'); + }); + + test('parses locked state', () => { + const state = 'locked'; + const parsed = WalletStateSchema.parse(state); + expect(parsed).toBe('locked'); + }); + + test('parses unlocked state', () => { + const state = 'unlocked'; + const parsed = WalletStateSchema.parse(state); + expect(parsed).toBe('unlocked'); + }); + + test('rejects invalid state', () => { + const state = 'invalid'; + expect(() => WalletStateSchema.parse(state)).toThrow(); + }); + + test('rejects non-string state', () => { + expect(() => WalletStateSchema.parse(123)).toThrow(); + expect(() => WalletStateSchema.parse(null)).toThrow(); + expect(() => WalletStateSchema.parse(undefined)).toThrow(); + }); + }); + + describe('isWalletState', () => { + test('returns true for uninitialized', () => { + expect(isWalletState('uninitialized')).toBe(true); + }); + + test('returns true for locked', () => { + expect(isWalletState('locked')).toBe(true); + }); + + test('returns true for unlocked', () => { + expect(isWalletState('unlocked')).toBe(true); + }); + + test('returns false for invalid state', () => { + expect(isWalletState('invalid')).toBe(false); + expect(isWalletState('pending')).toBe(false); + expect(isWalletState('active')).toBe(false); + }); + + test('returns false for non-string values', () => { + expect(isWalletState(null)).toBe(false); + expect(isWalletState(undefined)).toBe(false); + expect(isWalletState(123)).toBe(false); + expect(isWalletState({})).toBe(false); + expect(isWalletState([])).toBe(false); + }); + }); +}); + +describe('StorageKey', () => { + describe('enum values', () => { + test('has all required core wallet keys', () => { + expect(StorageKey.WALLET_STATE).toBe('walletState'); + expect(StorageKey.ACCOUNTS).toBe('accounts'); + expect(StorageKey.CURRENT_ACCOUNT_ID).toBe('currentAccountId'); + }); + + test('has all required session and security keys', () => { + expect(StorageKey.SESSION_KEYS).toBe('sessionKeys'); + expect(StorageKey.SETTINGS).toBe('settings'); + expect(StorageKey.PASSWORD_HASH).toBe('passwordHash'); + }); + + test('has all required transaction history keys', () => { + expect(StorageKey.TRANSACTIONS).toBe('transactions'); + expect(StorageKey.PENDING_OPERATIONS).toBe('pendingOperations'); + }); + + test('has network configuration key', () => { + expect(StorageKey.NETWORK).toBe('network'); + }); + + test('all keys are unique strings', () => { + const values = Object.values(StorageKey); + const uniqueValues = new Set(values); + expect(uniqueValues.size).toBe(values.length); + expect(values.every((v) => typeof v === 'string')).toBe(true); + }); + }); + + describe('usage patterns', () => { + test('can be used as object keys', () => { + const storage: Record = {}; + storage[StorageKey.WALLET_STATE] = 'locked'; + storage[StorageKey.ACCOUNTS] = []; + storage[StorageKey.SESSION_KEYS] = {}; + + expect(storage[StorageKey.WALLET_STATE]).toBe('locked'); + expect(Array.isArray(storage[StorageKey.ACCOUNTS])).toBe(true); + expect(typeof storage[StorageKey.SESSION_KEYS]).toBe('object'); + }); + + test('can be iterated', () => { + const keys = Object.values(StorageKey); + expect(keys.length).toBeGreaterThan(0); + expect(keys.every((k) => typeof k === 'string')).toBe(true); + }); + }); + + describe('type safety', () => { + test('prevents typos in storage access', () => { + // This test verifies that the enum provides type safety + const key: StorageKey = StorageKey.ACCOUNTS; + expect(typeof key).toBe('string'); + + // Accessing with invalid key should be caught by TypeScript + // @ts-expect-error - Testing that invalid keys are caught + const invalid: StorageKey = 'invalidKey'; + }); + }); +}); diff --git a/packages/types/src/guards.ts b/packages/types/src/guards.ts index 52392c2..0b83503 100644 --- a/packages/types/src/guards.ts +++ b/packages/types/src/guards.ts @@ -4,6 +4,8 @@ import { SmartAccount } from './smart-account'; import { SessionKey } from './session-key'; +import { UserOperation, TransactionResult } from './user-operation'; +import { WalletState } from './wallet'; export function isSmartAccount(value: unknown): value is SmartAccount { if (typeof value !== 'object' || value === null) return false; @@ -29,3 +31,41 @@ export function isValidPermission(value: unknown): boolean { if (typeof value !== 'number') return false; return [0, 1, 2].includes(value); } + +/** + * Type guard for UserOperation. + */ +export function isUserOperation(value: unknown): value is UserOperation { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + typeof v.id === 'string' && + typeof v.type === 'string' && + typeof v.operation === 'object' && + v.operation !== null && + typeof v.createdAt === 'number' + ); +} + +/** + * Type guard for TransactionResult. + */ +export function isTransactionResult(value: unknown): value is TransactionResult { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + typeof v.status === 'string' && + ['success', 'failure', 'pending'].includes(v.status as string) && + typeof v.timestamp === 'number' && + (v.hash === undefined || typeof v.hash === 'string') && + (v.ledger === undefined || typeof v.ledger === 'number') && + (v.error === undefined || typeof v.error === 'string') + ); +} + +/** + * Type guard for WalletState. + */ +export function isWalletState(value: unknown): value is WalletState { + return typeof value === 'string' && ['uninitialized', 'locked', 'unlocked'].includes(value); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7e2863c..40f6099 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -28,5 +28,8 @@ export interface NetworkConfig { export * from './stellar'; export * from './smart-account'; export * from './session-key'; +export * from './user-operation'; +export * from './wallet'; export * from './guards'; export * from './schemas'; + diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index 87fa267..16c1a9a 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -4,3 +4,5 @@ export { SmartAccountSchema, AccountMetadataSchema } from './smart-account'; export { SessionKeySchema } from './session-key'; +export { UserOperationSchema, TransactionResultSchema } from './user-operation'; +export { WalletStateSchema } from './wallet'; diff --git a/packages/types/src/user-operation.ts b/packages/types/src/user-operation.ts new file mode 100644 index 0000000..5c0ed79 --- /dev/null +++ b/packages/types/src/user-operation.ts @@ -0,0 +1,64 @@ +/** + * User operation types for smart account transactions. + * Represents an operation to be executed via the Soroban contract. + */ + +import { z } from 'zod'; +import { Operation } from '@stellar/stellar-sdk'; + +/** + * UserOperation represents a smart account operation to be executed via the contract. + * This is the primary abstraction for operations within the account abstraction layer. + * + * Fields: + * - `id`: Unique identifier for this operation + * - `type`: Operation type (e.g., 'payment', 'invoke', 'manage_data') + * - `operation`: The underlying Stellar Operation object + * - `gasLimit`: Maximum gas units to consume (for future use with fee abstraction) + * - `createdAt`: Unix timestamp (ms) when the operation was created + */ +export interface UserOperation { + id: string; + type: string; + operation: Operation; + gasLimit?: number; + createdAt: number; +} + +/** + * TransactionResult represents the result of submitting a transaction. + * Contains status, hash, ledger, and optional error information. + * + * Fields: + * - `status`: Result status ('success', 'failure', 'pending') + * - `hash`: Transaction hash (if available) + * - `ledger`: Ledger sequence number (if confirmed) + * - `error`: Error message (if failed) + * - `timestamp`: Unix timestamp (ms) when the result was recorded + */ +export interface TransactionResult { + status: 'success' | 'failure' | 'pending'; + hash?: string; + ledger?: number; + error?: string; + timestamp: number; +} + +export const UserOperationSchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + operation: z.object({}).passthrough(), // Operation object is complex, validate loosely + gasLimit: z.number().int().positive().optional(), + createdAt: z.number().int().nonnegative(), +}); + +export const TransactionResultSchema = z.object({ + status: z.enum(['success', 'failure', 'pending']), + hash: z.string().optional(), + ledger: z.number().int().positive().optional(), + error: z.string().optional(), + timestamp: z.number().int().nonnegative(), +}); + +export type UserOperationFromSchema = z.infer; +export type TransactionResultFromSchema = z.infer; diff --git a/packages/types/src/wallet.ts b/packages/types/src/wallet.ts new file mode 100644 index 0000000..10e980a --- /dev/null +++ b/packages/types/src/wallet.ts @@ -0,0 +1,40 @@ +/** + * Wallet state and storage types for the extension wallet. + */ + +import { z } from 'zod'; + +/** + * WalletState represents the lock state of the wallet. + * - 'uninitialized': Wallet has not been set up yet + * - 'locked': Wallet is initialized but currently locked (requires password/biometric) + * - 'unlocked': Wallet is unlocked and ready to sign transactions + */ +export type WalletState = 'uninitialized' | 'locked' | 'unlocked'; + +/** + * StorageKey enum provides typed keys for chrome.storage API access. + * Ensures type safety when reading/writing wallet state and data. + */ +export enum StorageKey { + // Core wallet state + WALLET_STATE = 'walletState', + ACCOUNTS = 'accounts', + CURRENT_ACCOUNT_ID = 'currentAccountId', + + // Session and security + SESSION_KEYS = 'sessionKeys', + SETTINGS = 'settings', + PASSWORD_HASH = 'passwordHash', + + // Transaction history + TRANSACTIONS = 'transactions', + PENDING_OPERATIONS = 'pendingOperations', + + // Network configuration + NETWORK = 'network', +} + +export const WalletStateSchema = z.enum(['uninitialized', 'locked', 'unlocked']); + +export type WalletStateFromSchema = z.infer;