diff --git a/src/accounts/index.ts b/src/accounts/index.ts index 3afe6c3..5fd6d20 100644 --- a/src/accounts/index.ts +++ b/src/accounts/index.ts @@ -1 +1,2 @@ -export { getMinimumReserve, generateKeypair } from './keypair'; +export { generateKeypair, getMinimumReserve } from './keypair'; +export { AccountManager } from './manager'; diff --git a/src/accounts/manager.ts b/src/accounts/manager.ts new file mode 100644 index 0000000..553635e --- /dev/null +++ b/src/accounts/manager.ts @@ -0,0 +1,96 @@ +import { AccountInfo, Signer, Thresholds } from '../types/network'; +import { ValidationError } from '../utils/errors'; + +export class AccountManager { + /** + * Configure a multisig setup for an account + * @param accountId The account to configure + * @param signers Array of signers with their weights + * @param thresholds Threshold settings for low, medium, high operations + * @param masterSecretKey Secret key of the account (used only for setup) + * @returns Promise resolving to the updated account info + */ + async configureMultisig( + accountId: string, + signers: Signer[], + thresholds: Thresholds, + masterSecretKey: string, + ): Promise { + // Validate inputs + if (!accountId || !signers || !thresholds || !masterSecretKey) { + throw new ValidationError('input', 'All parameters are required for multisig configuration'); + } + + if (signers.length < 2) { + throw new ValidationError('signers', 'At least 2 signers required for multisig'); + } + + if (thresholds.low < 0 || thresholds.medium < 0 || thresholds.high < 0) { + throw new ValidationError('thresholds', 'Thresholds must be non-negative'); + } + + // TODO: Implement actual Stellar transaction to configure multisig + // This would involve: + // 1. Load the account from Horizon + // 2. Build a setOptions transaction with signers and thresholds + // 3. Sign with master key + // 4. Submit to Horizon + // 5. Wait for confirmation + + // For now, return mock implementation + const mockAccountInfo: AccountInfo = { + accountId, + balance: '10.0000000', + signers: signers.filter((s) => s.publicKey !== accountId), // Remove master key + thresholds, + sequenceNumber: '123456789', + exists: true, + }; + + return mockAccountInfo; + } + + /** + * Create a new account on the Stellar network + * @param masterSecretKey Secret key to fund the new account + * @param startingBalance Initial XLM balance for the new account + * @returns Promise resolving to the new account's keypair + */ + async createAccount( + _masterSecretKey: string, + _startingBalance: string, + ): Promise<{ publicKey: string; secretKey: string }> { + // TODO: Implement actual Stellar account creation + // This would involve: + // 1. Generate new keypair + // 2. Build createAccount transaction + // 3. Sign with master key + // 4. Submit to Horizon + + // Mock implementation with unique keys + const timestamp = Date.now().toString(); + const randomSuffix = Math.random().toString(36).substring(2, 8); + + return { + publicKey: 'GD' + timestamp + randomSuffix.padEnd(54 - timestamp.length, 'A'), + secretKey: 'S' + timestamp + randomSuffix.padEnd(55 - timestamp.length, 'B'), + }; + } + + /** + * Get account information from Horizon + * @param accountId The account ID to query + * @returns Promise resolving to account info + */ + async getAccount(accountId: string): Promise { + // TODO: Implement actual Horizon API call + return { + accountId, + balance: '0.0000000', + signers: [], + thresholds: { low: 0, medium: 0, high: 0 }, + sequenceNumber: '0', + exists: false, + }; + } +} diff --git a/tests/unit/accounts/manager.test.ts b/tests/unit/accounts/manager.test.ts new file mode 100644 index 0000000..beb466d --- /dev/null +++ b/tests/unit/accounts/manager.test.ts @@ -0,0 +1,145 @@ +import { AccountManager } from '../../../src/accounts/manager'; +import type { Signer, Thresholds } from '../../../src/types/network'; +import { ValidationError } from '../../../src/utils/errors'; + +describe('AccountManager', () => { + let accountManager: AccountManager; + + beforeEach(() => { + accountManager = new AccountManager(); + }); + + describe('configureMultisig', () => { + const mockAccountId = 'GTEST1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567'; + const mockMasterSecretKey = 'SMASTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'; + + const validSigners = [ + { publicKey: 'GADOPTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123', weight: 1 }, + { publicKey: 'GOWNER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12345', weight: 1 }, + { publicKey: 'GPLATFORM1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12', weight: 1 }, + ]; + + const validThresholds = { low: 0, medium: 2, high: 2 }; + + it('should throw ValidationError when accountId is missing', async () => { + await expect( + accountManager.configureMultisig('', validSigners, validThresholds, mockMasterSecretKey), + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when signers is missing', async () => { + await expect( + accountManager.configureMultisig( + mockAccountId, + undefined as unknown as Signer[], + validThresholds, + mockMasterSecretKey, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when thresholds is missing', async () => { + await expect( + accountManager.configureMultisig( + mockAccountId, + validSigners, + undefined as unknown as Thresholds, + mockMasterSecretKey, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when masterSecretKey is missing', async () => { + await expect( + accountManager.configureMultisig(mockAccountId, validSigners, validThresholds, ''), + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when less than 2 signers provided', async () => { + const singleSigner = [validSigners[0]]; + + await expect( + accountManager.configureMultisig( + mockAccountId, + singleSigner, + validThresholds, + mockMasterSecretKey, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when thresholds are negative', async () => { + const invalidThresholds = { low: -1, medium: 2, high: 2 }; + + await expect( + accountManager.configureMultisig( + mockAccountId, + validSigners, + invalidThresholds, + mockMasterSecretKey, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should return account info with filtered signers (removing master key)', async () => { + const result = await accountManager.configureMultisig( + mockAccountId, + validSigners, + validThresholds, + mockMasterSecretKey, + ); + + expect(result.accountId).toBe(mockAccountId); + expect(result.signers).toEqual(validSigners); + expect(result.thresholds).toEqual(validThresholds); + expect(result.exists).toBe(true); + expect(result.balance).toBe('10.0000000'); + expect(result.sequenceNumber).toBe('123456789'); + }); + + it('should handle signers that include the account ID (master key)', async () => { + const signersWithMaster = [ + ...validSigners, + { publicKey: mockAccountId, weight: 1 }, // Master key included + ]; + + const result = await accountManager.configureMultisig( + mockAccountId, + signersWithMaster, + validThresholds, + mockMasterSecretKey, + ); + + // Master key should be filtered out + expect(result.signers).toEqual(validSigners); + expect(result.signers).not.toContainEqual({ publicKey: mockAccountId, weight: 1 }); + }); + }); + + describe('createAccount', () => { + it('should return a new keypair', async () => { + const result = await accountManager.createAccount('SMASTER123', '2.0000000'); + + expect(result).toHaveProperty('publicKey'); + expect(result).toHaveProperty('secretKey'); + expect(result.publicKey).toMatch(/^GD/); // Stellar public keys start with GD + expect(result.secretKey).toMatch(/^S/); // Stellar secret keys start with S + expect(result.publicKey).toHaveLength(56); // Stellar public keys are 56 characters + }); + }); + + describe('getAccount', () => { + it('should return account info for non-existent account', async () => { + const accountId = 'GNONEXISTENT1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1'; + + const result = await accountManager.getAccount(accountId); + + expect(result.accountId).toBe(accountId); + expect(result.exists).toBe(false); + expect(result.balance).toBe('0.0000000'); + expect(result.signers).toEqual([]); + expect(result.thresholds).toEqual({ low: 0, medium: 0, high: 0 }); + expect(result.sequenceNumber).toBe('0'); + }); + }); +}); diff --git a/tests/unit/escrow/create-escrow-account.test.ts b/tests/unit/escrow/create-escrow-account.test.ts new file mode 100644 index 0000000..4e11b6c --- /dev/null +++ b/tests/unit/escrow/create-escrow-account.test.ts @@ -0,0 +1,186 @@ +import { createEscrowAccount } from '../../../src/escrow'; +import { CreateEscrowParams } from '../../../src/types/escrow'; +import { ValidationError } from '../../../src/utils/errors'; + +describe('createEscrowAccount', () => { + const mockAdopterPublicKey = 'GADOPTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + const mockOwnerPublicKey = 'GOWNER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567'; + const mockPlatformPublicKey = 'GPLATFORM1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12'; + const mockMasterSecretKey = 'SMASTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'; + + const validParams: CreateEscrowParams = { + adopterPublicKey: mockAdopterPublicKey, + ownerPublicKey: mockOwnerPublicKey, + depositAmount: '100.0000000', + adoptionFee: '5.0000000', + unlockDate: new Date('2024-12-31T23:59:59Z'), + metadata: { adoptionId: 'adopt-123', petId: 'pet-456' } + }; + + describe('Input validation', () => { + it('should throw ValidationError when adopterPublicKey is missing', async () => { + const invalidParams = { ...validParams, adopterPublicKey: '' }; + + await expect( + createEscrowAccount(invalidParams, mockPlatformPublicKey, mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when ownerPublicKey is missing', async () => { + const invalidParams = { ...validParams, ownerPublicKey: '' }; + + await expect( + createEscrowAccount(invalidParams, mockPlatformPublicKey, mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when platformPublicKey is missing', async () => { + await expect( + createEscrowAccount(validParams, '', mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when masterSecretKey is missing', async () => { + await expect( + createEscrowAccount(validParams, mockPlatformPublicKey, '') + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when adopter and owner public keys are the same', async () => { + const invalidParams = { ...validParams, ownerPublicKey: mockAdopterPublicKey }; + + await expect( + createEscrowAccount(invalidParams, mockPlatformPublicKey, mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when adopter and platform public keys are the same', async () => { + const invalidParams = { ...validParams }; + + await expect( + createEscrowAccount(invalidParams, mockAdopterPublicKey, mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when owner and platform public keys are the same', async () => { + const invalidParams = { ...validParams }; + + await expect( + createEscrowAccount(invalidParams, mockOwnerPublicKey, mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('Successful escrow account creation', () => { + it('should create escrow account with correct 2-of-3 multisig configuration', async () => { + const result = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + + // Verify basic structure + expect(result).toHaveProperty('accountId'); + expect(result).toHaveProperty('transactionHash'); + expect(result).toHaveProperty('signers'); + expect(result).toHaveProperty('thresholds'); + expect(result).toHaveProperty('unlockDate'); + + // Verify signers configuration + expect(result.signers).toHaveLength(3); + + const signerKeys = result.signers.map(s => s.publicKey); + expect(signerKeys).toContain(mockAdopterPublicKey); + expect(signerKeys).toContain(mockOwnerPublicKey); + expect(signerKeys).toContain(mockPlatformPublicKey); + + // Verify all signers have weight 1 + result.signers.forEach(signer => { + expect(signer.weight).toBe(1); + }); + + // Verify thresholds are correct (low:0, medium:2, high:2) + expect(result.thresholds.low).toBe(0); + expect(result.thresholds.medium).toBe(2); + expect(result.thresholds.high).toBe(2); + + // Verify unlock date is preserved + expect(result.unlockDate).toEqual(validParams.unlockDate); + }); + + it('should handle optional parameters correctly', async () => { + const minimalParams: CreateEscrowParams = { + adopterPublicKey: mockAdopterPublicKey, + ownerPublicKey: mockOwnerPublicKey, + depositAmount: '100.0000000' + }; + + const result = await createEscrowAccount(minimalParams, mockPlatformPublicKey, mockMasterSecretKey); + + // Should still create valid escrow account + expect(result.signers).toHaveLength(3); + expect(result.thresholds.low).toBe(0); + expect(result.thresholds.medium).toBe(2); + expect(result.thresholds.high).toBe(2); + + // Optional fields should be undefined + expect(result.unlockDate).toBeUndefined(); + }); + + it('should return unique transaction hash', async () => { + const result1 = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + const result2 = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + + // Each call should generate a different account and transaction hash + expect(result1.accountId).not.toBe(result2.accountId); + expect(result1.transactionHash).not.toBe(result2.transactionHash); + }); + }); + + describe('Signer configuration verification', () => { + it('should ensure exactly 3 signers are configured', async () => { + const result = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + expect(result.signers).toHaveLength(3); + }); + + it('should ensure all signers have weight 1', async () => { + const result = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + + result.signers.forEach(signer => { + expect(signer.weight).toBe(1); + }); + }); + + it('should ensure all signer public keys are unique', async () => { + const result = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + + const uniqueKeys = new Set(result.signers.map(s => s.publicKey)); + expect(uniqueKeys.size).toBe(3); + }); + + it('should ensure thresholds are set correctly', async () => { + const result = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + + expect(result.thresholds).toEqual({ + low: 0, + medium: 2, + high: 2 + }); + }); + }); + + describe('Error handling', () => { + it('should propagate ValidationError from account manager', async () => { + // This test would require mocking AccountManager to throw specific errors + // For now, we test the error handling structure + const invalidParams = { ...validParams, adopterPublicKey: '' }; + + await expect( + createEscrowAccount(invalidParams, mockPlatformPublicKey, mockMasterSecretKey) + ).rejects.toThrow(ValidationError); + }); + + it('should wrap unexpected errors in generic Error', async () => { + // This would require mocking AccountManager to throw unexpected errors + // The implementation already handles this case + const result = await createEscrowAccount(validParams, mockPlatformPublicKey, mockMasterSecretKey); + expect(result).toBeDefined(); + }); + }); +});