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..54246a6 --- /dev/null +++ b/src/accounts/operations.ts @@ -0,0 +1,310 @@ +import { + Account, + 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 b800f4b..e1f75f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,54 +1,13 @@ 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 { AccountManager } from './accounts'; export type { - CreateEscrowParams, - Signer, - Thresholds, - EscrowAccount, - Distribution, - ReleaseParams, - ReleasedPayment, - ReleaseResult, - Percentage, - LockFundsParams, - LockResult, -} 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 -export type { SubmitResult, TransactionStatus, BuildParams, Operation } from './types/transaction'; - -// 6. Standalone functions -export { - createEscrowAccount, - calculateStartingBalance, - lockCustodyFunds, - EscrowManager, - handleDispute, - anchorTrustHash, - verifyEventHash, -} from './escrow'; -export { buildMultisigTransaction, buildSetOptionsOp } from './transactions'; -export { getMinimumReserve, generateKeypair } from './accounts'; + 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'; +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/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', + }); + }); +});