diff --git a/package-lock.json b/package-lock.json index 2fa5621..f3be0d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1494,6 +1495,7 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1574,6 +1576,7 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -1785,6 +1788,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2211,6 +2215,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2810,6 +2815,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3872,6 +3878,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5885,6 +5892,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6106,6 +6114,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/accounts/index.ts b/src/accounts/index.ts index 3afe6c3..3b881ac 100644 --- a/src/accounts/index.ts +++ b/src/accounts/index.ts @@ -1 +1,189 @@ -export { getMinimumReserve, generateKeypair } from './keypair'; +import { + BASE_FEE, + Horizon, + Keypair, + Networks, + NotFoundError, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { AccountInfo, SDKConfig } from '../types/network'; +import { DEFAULT_MAX_FEE } from '../utils/constants'; +import { AccountNotFoundError, HorizonSubmitError, ValidationError } from '../utils/errors'; +import { isValidPublicKey } from '../utils/validation'; + +interface HorizonAccountResponse { + id: string; + sequence: string; + balances?: Array<{ + asset_type?: string; + balance?: string; + }>; + signers?: Array<{ + key: string; + weight: number; + }>; + thresholds?: { + low_threshold?: number; + med_threshold?: number; + high_threshold?: number; + }; +} + +const DEFAULT_HORIZON_URL = process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'; + +export interface CreateAccountParams { + publicKey: string; + startingBalance?: string; +} + +export interface CreateAccountResult { + accountId: string; + transactionHash: string; + startingBalance: string; +} + +interface HorizonClient { + loadAccount(accountId: string): Promise; + fetchBaseFee(): Promise; + submitTransaction(transaction: unknown): Promise<{ hash: string }>; +} + +interface TransactionSourceAccount { + accountId(): string; + sequenceNumber(): string; + incrementSequenceNumber(): void; +} + +function buildMissingAccountInfo(publicKey: string): AccountInfo { + return { + accountId: publicKey, + balance: '0', + signers: [], + thresholds: { + low: 0, + medium: 0, + high: 0, + }, + sequenceNumber: '0', + exists: false, + }; +} + +function mapAccountInfo(account: HorizonAccountResponse): AccountInfo { + const nativeBalance = account.balances?.find(balance => balance.asset_type === 'native')?.balance || '0'; + + return { + accountId: account.id, + balance: nativeBalance, + signers: (account.signers || []).map(signer => ({ + publicKey: signer.key, + weight: signer.weight, + })), + thresholds: { + low: account.thresholds?.low_threshold || 0, + medium: account.thresholds?.med_threshold || 0, + high: account.thresholds?.high_threshold || 0, + }, + sequenceNumber: account.sequence, + exists: true, + }; +} + +export async function verifyAccount( + publicKey: string, + horizonUrl: string = DEFAULT_HORIZON_URL, +): Promise { + if (!isValidPublicKey(publicKey)) { + throw new ValidationError('publicKey', 'Invalid Stellar public key'); + } + + let response: Response; + + try { + response = await fetch(`${horizonUrl.replace(/\/$/, '')}/accounts/${publicKey}`); + } catch { + throw new HorizonSubmitError('network_error'); + } + + if (response.status === 404) { + return buildMissingAccountInfo(publicKey); + } + + if (!response.ok) { + throw new HorizonSubmitError(`http_${response.status}`); + } + + const account = (await response.json()) as HorizonAccountResponse; + return mapAccountInfo(account); +} + +function getNetworkPassphrase(config: SDKConfig): string { + if (config.networkPassphrase) { + return config.networkPassphrase; + } + + return config.network === 'public' ? Networks.PUBLIC : Networks.TESTNET; +} + +function createHorizonClient(horizonUrl: string): HorizonClient { + return new Horizon.Server(horizonUrl, { + allowHttp: horizonUrl.startsWith('http://'), + }); +} + +export async function createAccount( + params: CreateAccountParams, + config: SDKConfig, + horizonClient: HorizonClient = createHorizonClient(config.horizonUrl), +): Promise { + const { publicKey, startingBalance = '2.5' } = params; + + if (!isValidPublicKey(publicKey)) { + throw new ValidationError('publicKey', 'Invalid Stellar public key'); + } + + const masterKeypair = Keypair.fromSecret(config.masterSecretKey); + + let masterAccount: TransactionSourceAccount; + try { + masterAccount = await horizonClient.loadAccount(masterKeypair.publicKey()); + } catch (error) { + if (error instanceof NotFoundError) { + throw new AccountNotFoundError(masterKeypair.publicKey()); + } + + throw new HorizonSubmitError('load_account_failed'); + } + + try { + const fee = String(config.maxFee || (await horizonClient.fetchBaseFee()) || DEFAULT_MAX_FEE || BASE_FEE); + const transaction = new TransactionBuilder(masterAccount, { + fee, + networkPassphrase: getNetworkPassphrase(config), + }) + .addOperation(Operation.createAccount({ + destination: publicKey, + startingBalance, + })) + .setTimeout(config.transactionTimeout || 180) + .build(); + + transaction.sign(masterKeypair); + + const response = await horizonClient.submitTransaction(transaction); + + return { + accountId: publicKey, + transactionHash: response.hash, + startingBalance, + }; + } catch (error) { + if (error instanceof AccountNotFoundError || error instanceof ValidationError) { + throw error; + } + + const resultCode = error instanceof Error ? error.message : 'submit_failed'; + throw new HorizonSubmitError(resultCode); + } +} diff --git a/src/index.ts b/src/index.ts index 083567c..f105452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,54 +1,4 @@ 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 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 } from './transactions'; -export { getMinimumReserve, generateKeypair } from './accounts'; +export { createAccount, verifyAccount } from './accounts'; +export type { AccountInfo } from './types/network'; +export type { CreateAccountParams, CreateAccountResult } from './accounts'; diff --git a/tests/unit/accounts/createAccount.test.ts b/tests/unit/accounts/createAccount.test.ts new file mode 100644 index 0000000..788ef30 --- /dev/null +++ b/tests/unit/accounts/createAccount.test.ts @@ -0,0 +1,68 @@ +import { Account, Keypair, NotFoundError } from '@stellar/stellar-sdk'; +import { createAccount } from '../../../src/accounts'; +import { SDKConfig } from '../../../src/types/network'; +import { AccountNotFoundError, HorizonSubmitError, ValidationError } from '../../../src/utils/errors'; + +const destinationKeypair = Keypair.random(); +const masterKeypair = Keypair.random(); +const VALID_PUBLIC_KEY = destinationKeypair.publicKey(); + +const baseConfig: SDKConfig = { + network: 'testnet', + horizonUrl: 'http://mock-horizon.test', + masterSecretKey: masterKeypair.secret(), +}; + +describe('createAccount', () => { + it('creates and funds a new account', async () => { + const horizonClient = { + loadAccount: jest.fn().mockResolvedValue(new Account(masterKeypair.publicKey(), '123456789')), + fetchBaseFee: jest.fn().mockResolvedValue(100), + submitTransaction: jest.fn().mockResolvedValue({ + hash: 'abc123', + }), + }; + + await expect(createAccount({ publicKey: VALID_PUBLIC_KEY }, baseConfig, horizonClient)).resolves.toEqual({ + accountId: VALID_PUBLIC_KEY, + transactionHash: 'abc123', + startingBalance: '2.5', + }); + + expect(horizonClient.loadAccount).toHaveBeenCalledTimes(1); + expect(horizonClient.submitTransaction).toHaveBeenCalledTimes(1); + }); + + it('throws ValidationError for an invalid key', async () => { + const horizonClient = { + loadAccount: jest.fn(), + fetchBaseFee: jest.fn(), + submitTransaction: jest.fn(), + }; + + await expect(createAccount({ publicKey: 'BAD_KEY' }, baseConfig, horizonClient)).rejects.toBeInstanceOf(ValidationError); + expect(horizonClient.loadAccount).not.toHaveBeenCalled(); + }); + + it('throws AccountNotFoundError if the master account is missing', async () => { + const horizonClient = { + loadAccount: jest.fn().mockRejectedValue(new NotFoundError('missing', { status: 404 })), + fetchBaseFee: jest.fn(), + submitTransaction: jest.fn(), + }; + + await expect(createAccount({ publicKey: VALID_PUBLIC_KEY }, baseConfig, horizonClient)).rejects.toBeInstanceOf(AccountNotFoundError); + }); + + it('throws HorizonSubmitError when the master balance is insufficient', async () => { + const horizonClient = { + loadAccount: jest.fn().mockResolvedValue(new Account(masterKeypair.publicKey(), '123456789')), + fetchBaseFee: jest.fn().mockResolvedValue(100), + submitTransaction: jest.fn().mockRejectedValue(new Error('op_underfunded')), + }; + + await expect( + createAccount({ publicKey: VALID_PUBLIC_KEY, startingBalance: '5.0' }, baseConfig, horizonClient), + ).rejects.toBeInstanceOf(HorizonSubmitError); + }); +}); diff --git a/tests/unit/accounts/index.test.ts b/tests/unit/accounts/index.test.ts new file mode 100644 index 0000000..728dd70 --- /dev/null +++ b/tests/unit/accounts/index.test.ts @@ -0,0 +1,86 @@ +import { verifyAccount } from '../../../src/accounts'; +import { HorizonSubmitError, ValidationError } from '../../../src/utils/errors'; + +const VALID_PUBLIC_KEY = 'GADOPTER111111111111111111111111111111111111111111111111'; +const MOCK_HORIZON_URL = 'http://mock-horizon.test'; + +describe('verifyAccount', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('returns full account details when the account exists', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: VALID_PUBLIC_KEY, + sequence: '123456789', + balances: [ + { asset_type: 'credit_alphanum4', balance: '25.0000000' }, + { asset_type: 'native', balance: '100.5000000' }, + ], + signers: [ + { key: VALID_PUBLIC_KEY, weight: 1 }, + { key: 'GSIGNER11111111111111111111111111111111111111111111111111', weight: 2 }, + ], + thresholds: { + low_threshold: 1, + med_threshold: 2, + high_threshold: 3, + }, + }), + }) as typeof fetch; + + await expect(verifyAccount(VALID_PUBLIC_KEY, MOCK_HORIZON_URL)).resolves.toEqual({ + accountId: VALID_PUBLIC_KEY, + balance: '100.5000000', + signers: [ + { publicKey: VALID_PUBLIC_KEY, weight: 1 }, + { publicKey: 'GSIGNER11111111111111111111111111111111111111111111111111', weight: 2 }, + ], + thresholds: { + low: 1, + medium: 2, + high: 3, + }, + sequenceNumber: '123456789', + exists: true, + }); + + expect(global.fetch).toHaveBeenCalledWith(`${MOCK_HORIZON_URL}/accounts/${VALID_PUBLIC_KEY}`); + }); + + it('returns exists false for a non-existent account', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + }) as typeof fetch; + + await expect(verifyAccount(VALID_PUBLIC_KEY, MOCK_HORIZON_URL)).resolves.toEqual({ + accountId: VALID_PUBLIC_KEY, + balance: '0', + signers: [], + thresholds: { + low: 0, + medium: 0, + high: 0, + }, + sequenceNumber: '0', + exists: false, + }); + }); + + it('throws ValidationError for an invalid key', async () => { + await expect(verifyAccount('BAD_KEY', MOCK_HORIZON_URL)).rejects.toBeInstanceOf(ValidationError); + }); + + it('throws HorizonSubmitError on network failure', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('socket hang up')) as typeof fetch; + + await expect(verifyAccount(VALID_PUBLIC_KEY, MOCK_HORIZON_URL)).rejects.toBeInstanceOf(HorizonSubmitError); + }); +}); diff --git a/tests/unit/sdk-exports.test.ts b/tests/unit/sdk-exports.test.ts index 16c9602..0305f40 100644 --- a/tests/unit/sdk-exports.test.ts +++ b/tests/unit/sdk-exports.test.ts @@ -13,7 +13,6 @@ import defaultExport, { EscrowStatus, } from '../../src/index'; -// Requirements 1.3 describe('StellarSDK named and default export identity', () => { it('default export and named StellarSDK export are the same reference', () => { expect(defaultExport).toBe(StellarSDK);