Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

190 changes: 189 additions & 1 deletion src/accounts/index.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionSourceAccount>;
fetchBaseFee(): Promise<number>;
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<AccountInfo> {
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<CreateAccountResult> {
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);
}
}
56 changes: 3 additions & 53 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
68 changes: 68 additions & 0 deletions tests/unit/accounts/createAccount.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading