diff --git a/src/accounts/index.ts b/src/accounts/index.ts index 3afe6c3..247eea2 100644 --- a/src/accounts/index.ts +++ b/src/accounts/index.ts @@ -1 +1,104 @@ -export { getMinimumReserve, generateKeypair } from './keypair'; +import { AccountInfo } from '../types/network'; +import { SubmitResult } from '../types/transaction'; +import { + configureMultisigAccount, + createAccount, + CreateAccountOptions, + fundTestnetAccount, + generateAccount, + HorizonClient, + MergeAccountOptions, + mergeAccount, + StellarNetwork, + verifyAccount, + ConfigureMultisigOptions, +} from './operations'; + +export interface AccountManagerConfig { + horizonClient: HorizonClient; + masterSecretKey: string; + network: StellarNetwork; +} + +/** + * Account management API backed by an injected Horizon client. + */ +export class AccountManager { + constructor( + private readonly config: AccountManagerConfig, + ) {} + + /** + * Generates a fresh Stellar keypair. + */ + public generate() { + return generateAccount(); + } + + /** + * Creates a new Stellar account funded by the configured master account. + * @param options Account creation parameters. + */ + public create(options: CreateAccountOptions): Promise { + return createAccount({ + horizonClient: this.config.horizonClient, + masterSecretKey: this.config.masterSecretKey, + network: this.config.network, + options, + }); + } + + /** + * Verifies that an account exists and returns its current on-chain details. + * @param accountId Stellar public key to verify. + */ + public verify(accountId: string): Promise { + return verifyAccount({ + horizonClient: this.config.horizonClient, + accountId, + }); + } + + /** + * Configures multisig signer weights and thresholds for an account. + * @param options Multisig signer and threshold settings. + */ + public configureMultisig(options: ConfigureMultisigOptions): Promise { + return configureMultisigAccount({ + horizonClient: this.config.horizonClient, + network: this.config.network, + options, + }); + } + + /** + * Merges a source account into a destination account. + * @param options Source signer secret and merge destination. + */ + public merge(options: MergeAccountOptions): Promise { + return mergeAccount({ + horizonClient: this.config.horizonClient, + network: this.config.network, + options, + }); + } + + /** + * Funds an account on Stellar testnet through Friendbot. + * @param publicKey Stellar public key to fund. + */ + public fundTestnet(publicKey: string): Promise { + return fundTestnetAccount({ + horizonClient: this.config.horizonClient, + publicKey, + }); + } +} + +export type { + ConfigureMultisigOptions, + CreateAccountOptions, + HorizonClient, + MergeAccountOptions, + StellarNetwork, +} from './operations'; diff --git a/src/accounts/operations.ts b/src/accounts/operations.ts new file mode 100644 index 0000000..a8df266 --- /dev/null +++ b/src/accounts/operations.ts @@ -0,0 +1,309 @@ +import { + BASE_FEE, + Horizon, + Keypair, + Networks, + NetworkError, + NotFoundError, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { DEFAULT_TRANSACTION_TIMEOUT } from '../utils/constants'; +import { + AccountNotFoundError, + FriendbotError, + HorizonSubmitError, + SdkError, + ValidationError, +} from '../utils/errors'; +import { isValidAmount, isValidPublicKey, isValidSecretKey } from '../utils/validation'; +import { AccountInfo, KeypairResult } from '../types/network'; +import { SubmitResult } from '../types/transaction'; + +export type HorizonClient = Pick< + Horizon.Server, + 'fetchBaseFee' | 'friendbot' | 'loadAccount' | 'submitTransaction' +>; + +export type StellarNetwork = 'testnet' | 'public'; + +export interface CreateAccountOptions { + destination: string; + startingBalance: string; +} + +export interface ConfigureMultisigOptions { + sourceSecretKey: string; + signerPublicKey: string; + signerWeight: number; + masterWeight?: number; + lowThreshold?: number; + mediumThreshold?: number; + highThreshold?: number; +} + +export interface MergeAccountOptions { + sourceSecretKey: string; + destination: string; +} + +function getNetworkPassphrase(network: StellarNetwork): string { + return network === 'public' ? Networks.PUBLIC : Networks.TESTNET; +} + +function wrapSdkError(error: unknown, context: { accountId?: string; publicKey?: string } = {}): SdkError { + if (error instanceof SdkError) { + return error; + } + + if (error instanceof NotFoundError && context.accountId) { + return new AccountNotFoundError(context.accountId); + } + + if (error instanceof NetworkError) { + const response = error.getResponse(); + const transactionError = response.data?.title === 'Transaction Failed' ? response.data : undefined; + + if (transactionError?.extras?.result_codes?.transaction) { + return new HorizonSubmitError( + transactionError.extras.result_codes.transaction, + transactionError.extras.result_codes.operations ?? [], + ); + } + + if (context.publicKey && typeof response.status === 'number') { + return new FriendbotError(context.publicKey, response.status); + } + + return new SdkError(error.message, 'HORIZON_NETWORK_ERROR', true); + } + + if (error instanceof Error) { + return new SdkError(error.message, 'SDK_ERROR', false); + } + + return new SdkError('Unknown SDK error', 'SDK_ERROR', false); +} + +async function buildAndSubmitTransaction(args: { + horizonClient: HorizonClient; + network: StellarNetwork; + sourceSecretKey: string; + operation: ReturnType; +}): Promise; +async function buildAndSubmitTransaction(args: { + horizonClient: HorizonClient; + network: StellarNetwork; + sourceSecretKey: string; + operation: ReturnType; +}): Promise; +async function buildAndSubmitTransaction(args: { + horizonClient: HorizonClient; + network: StellarNetwork; + sourceSecretKey: string; + operation: ReturnType; +}): Promise; +async function buildAndSubmitTransaction({ + horizonClient, + network, + sourceSecretKey, + operation, +}: { + horizonClient: HorizonClient; + network: StellarNetwork; + sourceSecretKey: string; + operation: + | ReturnType + | ReturnType + | ReturnType; +}): Promise { + const sourceKeypair = Keypair.fromSecret(sourceSecretKey); + const sourceAccount = await horizonClient.loadAccount(sourceKeypair.publicKey()); + + const baseFee = await horizonClient.fetchBaseFee().catch(() => Number(BASE_FEE)); + const transaction = new TransactionBuilder(sourceAccount, { + fee: String(baseFee), + networkPassphrase: getNetworkPassphrase(network), + }) + .addOperation(operation) + .setTimeout(DEFAULT_TRANSACTION_TIMEOUT) + .build(); + + transaction.sign(sourceKeypair); + + const result = await horizonClient.submitTransaction(transaction); + + return { + successful: result.successful, + hash: result.hash, + ledger: result.ledger, + }; +} + +export function generateAccount(): KeypairResult { + try { + const keypair = Keypair.random(); + + return { + publicKey: keypair.publicKey(), + secretKey: keypair.secret(), + }; + } catch (error) { + throw wrapSdkError(error); + } +} + +export async function createAccount(args: { + horizonClient: HorizonClient; + masterSecretKey: string; + network: StellarNetwork; + options: CreateAccountOptions; +}): Promise { + const { horizonClient, masterSecretKey, network, options } = args; + + if (!isValidSecretKey(masterSecretKey)) { + throw new ValidationError('masterSecretKey', 'Invalid Stellar secret key'); + } + + if (!isValidPublicKey(options.destination)) { + throw new ValidationError('destination', 'Invalid Stellar public key'); + } + + if (!isValidAmount(options.startingBalance)) { + throw new ValidationError('startingBalance', 'Invalid Stellar amount'); + } + + try { + return await buildAndSubmitTransaction({ + horizonClient, + network, + sourceSecretKey: masterSecretKey, + operation: Operation.createAccount({ + destination: options.destination, + startingBalance: options.startingBalance, + }), + }); + } catch (error) { + throw wrapSdkError(error, { accountId: options.destination }); + } +} + +export async function verifyAccount(args: { + horizonClient: HorizonClient; + accountId: string; +}): Promise { + const { horizonClient, accountId } = args; + + if (!isValidPublicKey(accountId)) { + throw new ValidationError('accountId', 'Invalid Stellar public key'); + } + + try { + const account = await horizonClient.loadAccount(accountId); + const nativeBalance = account.balances.find(balance => balance.asset_type === 'native'); + + return { + accountId: account.accountId(), + balance: nativeBalance?.balance ?? '0', + signers: account.signers.map(signer => ({ + publicKey: signer.key, + weight: signer.weight, + })), + thresholds: { + low: account.thresholds.low_threshold, + medium: account.thresholds.med_threshold, + high: account.thresholds.high_threshold, + }, + sequenceNumber: account.sequenceNumber(), + exists: true, + }; + } catch (error) { + throw wrapSdkError(error, { accountId }); + } +} + +export async function configureMultisigAccount(args: { + horizonClient: HorizonClient; + network: StellarNetwork; + options: ConfigureMultisigOptions; +}): Promise { + const { horizonClient, network, options } = args; + + if (!isValidSecretKey(options.sourceSecretKey)) { + throw new ValidationError('sourceSecretKey', 'Invalid Stellar secret key'); + } + + if (!isValidPublicKey(options.signerPublicKey)) { + throw new ValidationError('signerPublicKey', 'Invalid Stellar public key'); + } + + try { + return await buildAndSubmitTransaction({ + horizonClient, + network, + sourceSecretKey: options.sourceSecretKey, + operation: Operation.setOptions({ + signer: { + ed25519PublicKey: options.signerPublicKey, + weight: options.signerWeight, + }, + masterWeight: options.masterWeight, + lowThreshold: options.lowThreshold, + medThreshold: options.mediumThreshold, + highThreshold: options.highThreshold, + }), + }); + } catch (error) { + throw wrapSdkError(error, { + accountId: Keypair.fromSecret(options.sourceSecretKey).publicKey(), + }); + } +} + +export async function mergeAccount(args: { + horizonClient: HorizonClient; + network: StellarNetwork; + options: MergeAccountOptions; +}): Promise { + const { horizonClient, network, options } = args; + + if (!isValidSecretKey(options.sourceSecretKey)) { + throw new ValidationError('sourceSecretKey', 'Invalid Stellar secret key'); + } + + if (!isValidPublicKey(options.destination)) { + throw new ValidationError('destination', 'Invalid Stellar public key'); + } + + try { + return await buildAndSubmitTransaction({ + horizonClient, + network, + sourceSecretKey: options.sourceSecretKey, + operation: Operation.accountMerge({ + destination: options.destination, + }), + }); + } catch (error) { + throw wrapSdkError(error, { + accountId: Keypair.fromSecret(options.sourceSecretKey).publicKey(), + }); + } +} + +export async function fundTestnetAccount(args: { + horizonClient: HorizonClient; + publicKey: string; +}): Promise { + const { horizonClient, publicKey } = args; + + if (!isValidPublicKey(publicKey)) { + throw new ValidationError('publicKey', 'Invalid Stellar public key'); + } + + try { + await horizonClient.friendbot(publicKey).call(); + } catch (error) { + throw wrapSdkError(error, { publicKey }); + } +} diff --git a/src/index.ts b/src/index.ts index 9ddbe3d..796a574 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,50 +1,15 @@ export const SDK_VERSION = '0.1.0'; - -// 1. Main class -export { StellarSDK } from './sdk'; -export { StellarSDK as default } from './sdk'; - -// 2. Error classes -export { - SdkError, - ValidationError, - AccountNotFoundError, - EscrowNotFoundError, - InsufficientBalanceError, - HorizonSubmitError, - TransactionTimeoutError, - MonitorTimeoutError, - FriendbotError, - ConditionMismatchError, -} from './utils/errors'; - -// 3. Escrow types (canonical source for Signer + Thresholds) +export { StellarSDK as default, StellarSDK } from './sdk'; +export { AccountManager } from './accounts'; export type { - CreateEscrowParams, - Signer, - Thresholds, - EscrowAccount, - Distribution, - ReleaseParams, - ReleasedPayment, - ReleaseResult, - Percentage, -} from './types/escrow'; -export { EscrowStatus, asPercentage } from './types/escrow'; - -// 4. Network types (Signer + Thresholds excluded to avoid conflict) -export type { SDKConfig, KeypairResult, AccountInfo, BalanceInfo } from './types/network'; - -// 5. Transaction types + AccountManagerConfig, + ConfigureMultisigOptions, + CreateAccountOptions, + HorizonClient, + MergeAccountOptions, + StellarNetwork, +} from './accounts'; +export type { AccountInfo, BalanceInfo, KeypairResult, SDKConfig, Signer, Thresholds } from './types/network'; export type { SubmitResult, TransactionStatus } from './types/transaction'; - -// 6. Standalone functions -export { - createEscrowAccount, - calculateStartingBalance, - lockCustodyFunds, - anchorTrustHash, - verifyEventHash, -} from './escrow'; -export { buildMultisigTransaction } from './transactions'; -export { getMinimumReserve, generateKeypair } from './accounts'; +export { EscrowStatus } from './types/escrow'; +export * from './utils/errors'; diff --git a/src/types/index.ts b/src/types/index.ts index bc207ee..64f54f1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,9 @@ -export * from './transaction'; -export * from './network'; -export * from './escrow'; +export type { + AccountInfo, + BalanceInfo, + KeypairResult, + SDKConfig, + Signer, + Thresholds, +} from './network'; +export type { SubmitResult, TransactionStatus } from './transaction'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 83a176b..7451a2d 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -15,11 +15,56 @@ export function isValidAmount(amount: string): boolean { return !isNaN(num) && num > 0 && /^\d+(\.\d{1,7})?$/.test(amount); } +function normalizePercentage(value: number): { scaledValue: bigint; scale: bigint } | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + + const normalized = value.toString().toLowerCase(); + const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/); + + if (!match) return null; + + const integerPart = match[1]; + const fractionalPart = match[2] ?? ''; + const exponent = Number.parseInt(match[3] ?? '0', 10); + const digits = `${integerPart}${fractionalPart}`.replace(/^0+(?=\d)/, '') || '0'; + const scalePower = fractionalPart.length - exponent; + + if (scalePower >= 0) { + return { + scaledValue: BigInt(digits), + scale: 10n ** BigInt(scalePower), + }; + } + + return { + scaledValue: BigInt(digits) * 10n ** BigInt(-scalePower), + scale: 1n, + }; +} + export function isValidDistribution( distribution: { recipient: string; percentage: number }[], ): boolean { if (!distribution || distribution.length === 0) return false; - if (!distribution.every((d) => isValidPublicKey(d.recipient))) return false; - const total = distribution.reduce((sum, d) => sum + d.percentage, 0); - return Math.round(total) === 100; + + const normalizedEntries = distribution.map(entry => { + if (!isValidPublicKey(entry.recipient)) return null; + if (typeof entry.percentage !== 'number' || entry.percentage <= 0 || entry.percentage > 100) return null; + + return normalizePercentage(entry.percentage); + }); + + if (normalizedEntries.some(entry => entry === null)) return false; + + const scale = normalizedEntries.reduce( + (maxScale, entry) => ((entry as { scale: bigint }).scale > maxScale ? (entry as { scale: bigint }).scale : maxScale), + 1n, + ); + + const total = normalizedEntries.reduce( + (sum, entry) => sum + ((entry as { scaledValue: bigint; scale: bigint }).scaledValue * (scale / (entry as { scale: bigint }).scale)), + 0n, + ); + + return total === 100n * scale; } diff --git a/tests/unit/accounts/account-manager.test.ts b/tests/unit/accounts/account-manager.test.ts new file mode 100644 index 0000000..7d0cdfe --- /dev/null +++ b/tests/unit/accounts/account-manager.test.ts @@ -0,0 +1,125 @@ +import { AccountManager } from '../../../src/accounts'; +import * as accountOperations from '../../../src/accounts/operations'; + +jest.mock('../../../src/accounts/operations', () => ({ + configureMultisigAccount: jest.fn(), + createAccount: jest.fn(), + fundTestnetAccount: jest.fn(), + generateAccount: jest.fn(), + mergeAccount: jest.fn(), + verifyAccount: jest.fn(), +})); + +describe('AccountManager', () => { + const horizonClient = { + fetchBaseFee: jest.fn(), + friendbot: jest.fn(), + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + const config = { + horizonClient, + masterSecretKey: 'SMASTER111111111111111111111111111111111111111111111111', + network: 'testnet' as const, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stores injected configuration on instantiation', () => { + const manager = new AccountManager(config); + + expect(manager).toBeInstanceOf(AccountManager); + }); + + it('delegates generate', () => { + const expected = { publicKey: 'GTEST', secretKey: 'STEST' }; + (accountOperations.generateAccount as jest.Mock).mockReturnValue(expected); + const manager = new AccountManager(config); + + expect(manager.generate()).toBe(expected); + expect(accountOperations.generateAccount).toHaveBeenCalledTimes(1); + }); + + it('delegates create', async () => { + const options = { destination: 'GDEST', startingBalance: '10' }; + const expected = { successful: true, hash: 'hash', ledger: 1 }; + (accountOperations.createAccount as jest.Mock).mockResolvedValue(expected); + const manager = new AccountManager(config); + + await expect(manager.create(options)).resolves.toEqual(expected); + expect(accountOperations.createAccount).toHaveBeenCalledWith({ + horizonClient, + masterSecretKey: config.masterSecretKey, + network: config.network, + options, + }); + }); + + it('delegates verify', async () => { + const expected = { + accountId: 'GACCOUNT', + balance: '100', + signers: [], + thresholds: { low: 1, medium: 2, high: 3 }, + sequenceNumber: '123', + exists: true, + }; + (accountOperations.verifyAccount as jest.Mock).mockResolvedValue(expected); + const manager = new AccountManager(config); + + await expect(manager.verify('GACCOUNT')).resolves.toEqual(expected); + expect(accountOperations.verifyAccount).toHaveBeenCalledWith({ + horizonClient, + accountId: 'GACCOUNT', + }); + }); + + it('delegates configureMultisig', async () => { + const options = { + sourceSecretKey: 'SSOURCE', + signerPublicKey: 'GSIGNER', + signerWeight: 1, + lowThreshold: 1, + mediumThreshold: 2, + highThreshold: 2, + }; + const expected = { successful: true, hash: 'hash', ledger: 2 }; + (accountOperations.configureMultisigAccount as jest.Mock).mockResolvedValue(expected); + const manager = new AccountManager(config); + + await expect(manager.configureMultisig(options)).resolves.toEqual(expected); + expect(accountOperations.configureMultisigAccount).toHaveBeenCalledWith({ + horizonClient, + network: config.network, + options, + }); + }); + + it('delegates merge', async () => { + const options = { sourceSecretKey: 'SSOURCE', destination: 'GDEST' }; + const expected = { successful: true, hash: 'hash', ledger: 3 }; + (accountOperations.mergeAccount as jest.Mock).mockResolvedValue(expected); + const manager = new AccountManager(config); + + await expect(manager.merge(options)).resolves.toEqual(expected); + expect(accountOperations.mergeAccount).toHaveBeenCalledWith({ + horizonClient, + network: config.network, + options, + }); + }); + + it('delegates fundTestnet', async () => { + (accountOperations.fundTestnetAccount as jest.Mock).mockResolvedValue(undefined); + const manager = new AccountManager(config); + + await expect(manager.fundTestnet('GFUND')).resolves.toBeUndefined(); + expect(accountOperations.fundTestnetAccount).toHaveBeenCalledWith({ + horizonClient, + publicKey: 'GFUND', + }); + }); +}); diff --git a/tests/unit/accounts/operations.test.ts b/tests/unit/accounts/operations.test.ts new file mode 100644 index 0000000..ef0070f --- /dev/null +++ b/tests/unit/accounts/operations.test.ts @@ -0,0 +1,272 @@ +import { + Keypair, + Operation, + TransactionBuilder, + Networks, +} from '@stellar/stellar-sdk'; +import { + generateAccount, + createAccount, + verifyAccount, + configureMultisigAccount, + mergeAccount, + fundTestnetAccount, +} from '../../../src/accounts/operations'; +import { + ValidationError, + AccountNotFoundError, + FriendbotError, + HorizonSubmitError, + SdkError, +} from '../../../src/utils/errors'; + +jest.mock('@stellar/stellar-sdk', () => { + const original = jest.requireActual('@stellar/stellar-sdk'); + return { + ...original, + Keypair: { + random: jest.fn(), + fromSecret: jest.fn(), + }, + Operation: { + createAccount: jest.fn(), + setOptions: jest.fn(), + accountMerge: jest.fn(), + }, + TransactionBuilder: jest.fn().mockImplementation(() => ({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + sign: jest.fn(), + }), + })), + }; +}); + +describe('Account Operations', () => { + const mockHorizonClient = { + fetchBaseFee: jest.fn(), + friendbot: jest.fn(), + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + const VALID_G = 'GD6W6HIKYOTU6BFEA6C2Z3ZZ7O7S26SSO2N5UCO5E7U7V7V7V7V7V7V7'; + const VALID_S = 'SA6W6HIKYOTU6BFEA6C2Z3ZZ7O7S26SSO2N5UCO5E7U7V7V7V7V7V7V7'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateAccount', () => { + it('generates a new keypair', () => { + const mockKeypair = { + publicKey: () => VALID_G, + secret: () => VALID_S, + }; + (Keypair.random as jest.Mock).mockReturnValue(mockKeypair); + + const result = generateAccount(); + expect(result).toEqual({ publicKey: VALID_G, secretKey: VALID_S }); + }); + + it('wraps errors', () => { + (Keypair.random as jest.Mock).mockImplementation(() => { + throw new Error('Random failed'); + }); + expect(() => generateAccount()).toThrow(SdkError); + }); + }); + + describe('createAccount', () => { + const options = { destination: VALID_G, startingBalance: '10' }; + + it('throws ValidationError for invalid secret key', async () => { + await expect( + createAccount({ + horizonClient: mockHorizonClient as any, + masterSecretKey: 'invalid', + network: 'testnet', + options, + }) + ).rejects.toThrow(ValidationError); + }); + + it('submits a createAccount transaction successfully', async () => { + const mockKeypair = { + publicKey: () => VALID_G, + secret: () => VALID_S, + }; + (Keypair.fromSecret as jest.Mock).mockReturnValue(mockKeypair); + mockHorizonClient.loadAccount.mockResolvedValue({ + sequenceNumber: () => '1', + }); + mockHorizonClient.fetchBaseFee.mockResolvedValue(100); + mockHorizonClient.submitTransaction.mockResolvedValue({ + successful: true, + hash: 'hash', + ledger: 100, + }); + + const result = await createAccount({ + horizonClient: mockHorizonClient as any, + masterSecretKey: VALID_S, + network: 'testnet', + options, + }); + + expect(result.successful).toBe(true); + expect(Operation.createAccount).toHaveBeenCalledWith(options); + }); + }); + + describe('verifyAccount', () => { + it('returns account info for existing account', async () => { + const mockAccount = { + accountId: () => VALID_G, + balances: [{ asset_type: 'native', balance: '100' }], + signers: [{ key: VALID_G, weight: 1 }], + thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 3 }, + sequenceNumber: () => '123', + }; + mockHorizonClient.loadAccount.mockResolvedValue(mockAccount); + + const result = await verifyAccount({ + horizonClient: mockHorizonClient as any, + accountId: VALID_G, + }); + + expect(result.accountId).toBe(VALID_G); + expect(result.exists).toBe(true); + }); + + it('wraps NotFoundError as AccountNotFoundError', async () => { + const { NotFoundError } = jest.requireActual('@stellar/stellar-sdk'); + mockHorizonClient.loadAccount.mockRejectedValue(new NotFoundError('Account Not Found', { status: 404, data: {} })); + + await expect( + verifyAccount({ + horizonClient: mockHorizonClient as any, + accountId: VALID_G, + }) + ).rejects.toThrow(AccountNotFoundError); + }); + }); + + describe('configureMultisigAccount', () => { + it('submits a setOptions transaction', async () => { + const mockKeypair = { + publicKey: () => VALID_G, + secret: () => VALID_S, + }; + (Keypair.fromSecret as jest.Mock).mockReturnValue(mockKeypair); + mockHorizonClient.loadAccount.mockResolvedValue({ sequenceNumber: () => '1' }); + mockHorizonClient.fetchBaseFee.mockResolvedValue(100); + mockHorizonClient.submitTransaction.mockResolvedValue({ successful: true }); + + const options = { + sourceSecretKey: VALID_S, + signerPublicKey: VALID_G, + signerWeight: 1, + }; + + await configureMultisigAccount({ + horizonClient: mockHorizonClient as any, + network: 'public', + options, + }); + + expect(Operation.setOptions).toHaveBeenCalled(); + }); + }); + + describe('mergeAccount', () => { + it('submits an accountMerge transaction', async () => { + const mockKeypair = { + publicKey: () => VALID_G, + secret: () => VALID_S, + }; + (Keypair.fromSecret as jest.Mock).mockReturnValue(mockKeypair); + mockHorizonClient.loadAccount.mockResolvedValue({ sequenceNumber: () => '1' }); + mockHorizonClient.fetchBaseFee.mockResolvedValue(100); + mockHorizonClient.submitTransaction.mockResolvedValue({ successful: true }); + + await mergeAccount({ + horizonClient: mockHorizonClient as any, + network: 'testnet', + options: { sourceSecretKey: VALID_S, destination: VALID_G }, + }); + + expect(Operation.accountMerge).toHaveBeenCalledWith({ destination: VALID_G }); + }); + }); + + describe('fundTestnetAccount', () => { + it('calls friendbot', async () => { + mockHorizonClient.friendbot.mockReturnValue({ + call: jest.fn().mockResolvedValue({}), + }); + + await fundTestnetAccount({ + horizonClient: mockHorizonClient as any, + publicKey: VALID_G, + }); + + expect(mockHorizonClient.friendbot).toHaveBeenCalledWith(VALID_G); + }); + + it('wraps NetworkError from friendbot', async () => { + const { NetworkError } = jest.requireActual('@stellar/stellar-sdk'); + const mockError = new NetworkError('Friendbot failed'); + mockError.getResponse = () => ({ status: 400, data: { title: 'Bad Request' } } as any); + + mockHorizonClient.friendbot.mockReturnValue({ + call: jest.fn().mockRejectedValue(mockError), + }); + + await expect( + fundTestnetAccount({ + horizonClient: mockHorizonClient as any, + publicKey: VALID_G, + }) + ).rejects.toThrow(FriendbotError); + }); + }); + + describe('wrapSdkError', () => { + it('handles Horizon transaction failure', async () => { + const { NetworkError } = jest.requireActual('@stellar/stellar-sdk'); + const mockError = new NetworkError('Transaction failed'); + mockError.getResponse = () => ({ + status: 400, + data: { + title: 'Transaction Failed', + extras: { + result_codes: { + transaction: 'tx_failed', + operations: ['op_failed'] + } + } + } + } as any); + + mockHorizonClient.loadAccount.mockResolvedValue({ sequenceNumber: () => '1' }); + mockHorizonClient.submitTransaction.mockRejectedValue(mockError); + + await expect(createAccount({ + horizonClient: mockHorizonClient as any, + masterSecretKey: VALID_S, + network: 'testnet', + options: { destination: VALID_G, startingBalance: '10' } + })).rejects.toThrow(HorizonSubmitError); + }); + + it('returns generic SdkError for unknown errors', async () => { + mockHorizonClient.loadAccount.mockRejectedValue(new Error('Unknown')); + await expect(verifyAccount({ + horizonClient: mockHorizonClient as any, + accountId: VALID_G + })).rejects.toThrow(SdkError); + }); + }); +}); diff --git a/tests/unit/utils/validation.test.ts b/tests/unit/utils/validation.test.ts index b148e2c..837e1b4 100644 --- a/tests/unit/utils/validation.test.ts +++ b/tests/unit/utils/validation.test.ts @@ -34,19 +34,49 @@ describe('isValidAmount', () => { }); describe('isValidDistribution', () => { - it('accepts 60/40 split', () => - expect( - isValidDistribution([ - { recipient: VALID_KEY_G, percentage: 60 }, - { recipient: VALID_KEY_G, percentage: 40 }, - ]), - ).toBe(true)); - it('rejects sum of 90', () => - expect( - isValidDistribution([ - { recipient: VALID_KEY_G, percentage: 60 }, - { recipient: VALID_KEY_G, percentage: 30 }, - ]), - ).toBe(false)); + it('accepts 60/40 split', () => expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 60 }, + { recipient: VALID_KEY_G, percentage: 40 }, + ])).toBe(true)); + it('accepts decimal percentages that sum exactly to 100', () => expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 33.33 }, + { recipient: VALID_KEY_G, percentage: 33.33 }, + { recipient: VALID_KEY_G, percentage: 33.34 }, + ])).toBe(true)); + it('rejects sum of 90', () => expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 60 }, + { recipient: VALID_KEY_G, percentage: 30 }, + ])).toBe(false)); + it('rejects decimal percentages that do not sum exactly to 100', () => expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 33.33 }, + { recipient: VALID_KEY_G, percentage: 33.33 }, + { recipient: VALID_KEY_G, percentage: 33.33 }, + ])).toBe(false)); it('rejects empty array', () => expect(isValidDistribution([])).toBe(false)); + it('rejects invalid recipient', () => expect(isValidDistribution([ + { recipient: 'BADKEY', percentage: 100 }, + ])).toBe(false)); + it('rejects zero and over-100 percentages', () => { + expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 0 }, + { recipient: VALID_KEY_G, percentage: 100 }, + ])).toBe(false); + + expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 101 }, + ])).toBe(false); + }); + + it('handles scientific notation in percentages', () => { + // 1.0e2 is 100 + expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 1.0e2 }, + ])).toBe(true); + + // 5.0e1 + 5.0e1 = 50 + 50 = 100 + expect(isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 5.0e1 }, + { recipient: VALID_KEY_G, percentage: 5.0e1 }, + ])).toBe(true); + }); });