diff --git a/Changelog.md b/Changelog.md index 5c0b471..7df9b82 100644 --- a/Changelog.md +++ b/Changelog.md @@ -16,6 +16,8 @@ - `SubmitResult` type: `successful`, `hash`, `ledger`, `resultXdr` (`src/types/transaction.ts`) - `TransactionStatus` type: `confirmed`, `confirmations`, `ledger`, `hash`, `successful` (`src/types/transaction.ts`) - Re-exported all transaction types from `src/types/index.ts` +- `releaseFunds()` preflight validation for escrow lifecycle: validates escrow public key and distribution payload, loads escrow account from Horizon, maps missing accounts to `EscrowNotFoundError`, enforces positive native balance with `InsufficientBalanceError`, and returns the validated account for downstream transaction building (`src/escrow/index.ts`) +- Extended escrow validation unit tests for early-fail behavior, not-found/error mapping, idempotency, and edge native-balance handling (`tests/unit/escrow/index.test.ts`) ## [0.1.0] - 2026-03-23 diff --git a/src/escrow/index.ts b/src/escrow/index.ts index 3dbf7c1..f0b4284 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -1,3 +1,80 @@ +import { Horizon, NotFoundError } from '@stellar/stellar-sdk'; + +import { ReleaseParams } from '../types/escrow'; +import { TESTNET_HORIZON_URL } from '../utils/constants'; +import { + EscrowNotFoundError, + InsufficientBalanceError, + ValidationError, +} from '../utils/errors'; +import { isValidDistribution, isValidPublicKey } from '../utils/validation'; + +export interface ReleaseFundsValidationDeps { + loadAccount?: ( + escrowAccountId: string, + ) => Promise; +} + +function createDefaultLoadAccount( +): (escrowAccountId: string) => Promise { + const server = new Horizon.Server(TESTNET_HORIZON_URL); + return (escrowAccountId: string) => server.loadAccount(escrowAccountId); +} + +function getNativeBalance(account: Horizon.AccountResponse): string { + const nativeBalance = account.balances.find( + balance => balance.asset_type === 'native', + ); + + return nativeBalance?.balance ?? '0'; +} + +export async function validateReleaseFundsParams( + { escrowAccountId, distribution }: ReleaseParams, + deps: ReleaseFundsValidationDeps = {}, +): Promise { + if (!isValidPublicKey(escrowAccountId)) { + throw new ValidationError( + 'escrowAccountId', + 'Invalid escrow account public key', + ); + } + + if (!isValidDistribution(distribution)) { + throw new ValidationError( + 'distribution', + 'Invalid escrow distribution payload', + ); + } + + const loadAccount = deps.loadAccount ?? createDefaultLoadAccount(); + + let account: Horizon.AccountResponse; + try { + account = await loadAccount(escrowAccountId); + } catch (error) { + if (error instanceof NotFoundError) { + throw new EscrowNotFoundError(escrowAccountId); + } + + throw error; + } + + const nativeBalance = getNativeBalance(account); + if (Number(nativeBalance) <= 0) { + throw new InsufficientBalanceError('greater than 0', nativeBalance); + } + + return account; +} + +export async function releaseFunds( + params: ReleaseParams, + deps: ReleaseFundsValidationDeps = {}, +): Promise { + return validateReleaseFundsParams(params, deps); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createEscrowAccount(..._args: unknown[]): unknown { return undefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/index.ts b/src/index.ts index a2007d7..971246b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,13 @@ export type { SDKConfig, KeypairResult, AccountInfo, BalanceInfo } from './types export type { SubmitResult, TransactionStatus } from './types/transaction'; // 6. Standalone functions -export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash } from './escrow'; +export { + createEscrowAccount, + lockCustodyFunds, + anchorTrustHash, + verifyEventHash, + validateReleaseFundsParams, + releaseFunds, +} from './escrow'; export { buildMultisigTransaction } from './transactions'; export { getMinimumReserve } from './accounts'; diff --git a/tests/unit/escrow/index.test.ts b/tests/unit/escrow/index.test.ts index 1c7d960..9588770 100644 --- a/tests/unit/escrow/index.test.ts +++ b/tests/unit/escrow/index.test.ts @@ -1,9 +1,65 @@ +import { Horizon, NotFoundError } from '@stellar/stellar-sdk'; + import { + releaseFunds, + validateReleaseFundsParams, createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash, } from '../../../src/escrow'; +import { asPercentage } from '../../../src/types/escrow'; +import { + EscrowNotFoundError, + InsufficientBalanceError, + ValidationError, +} from '../../../src/utils/errors'; + +const VALID_ESCROW_ACCOUNT_ID = + 'GADOPTER111111111111111111111111111111111111111111111111'; +const VALID_RECIPIENT_A = + 'GOWNER11111111111111111111111111111111111111111111111111'; +const VALID_RECIPIENT_B = + 'GOWNER22222222222222222222222222222222222222222222222222'; + +function createMockAccount(balance: string) { + return { + id: 'account-id', + paging_token: '1', + account_id: VALID_ESCROW_ACCOUNT_ID, + sequence: '123', + subentry_count: 0, + last_modified_ledger: 1, + last_modified_time: '2026-03-27T00:00:00Z', + thresholds: { low_threshold: 1, med_threshold: 1, high_threshold: 1 }, + flags: { + auth_required: false, + auth_revocable: false, + auth_immutable: false, + auth_clawback_enabled: false, + }, + balances: [{ asset_type: 'native', balance }], + signers: [], + data: {}, + _links: { + self: { href: 'https://horizon-testnet.stellar.org/accounts/mock' }, + transactions: { href: 'https://horizon-testnet.stellar.org/accounts/mock/transactions' }, + operations: { href: 'https://horizon-testnet.stellar.org/accounts/mock/operations' }, + payments: { href: 'https://horizon-testnet.stellar.org/accounts/mock/payments' }, + effects: { href: 'https://horizon-testnet.stellar.org/accounts/mock/effects' }, + offers: { href: 'https://horizon-testnet.stellar.org/accounts/mock/offers' }, + trades: { href: 'https://horizon-testnet.stellar.org/accounts/mock/trades' }, + data: { href: 'https://horizon-testnet.stellar.org/accounts/mock/data/{key}', templated: true }, + }, + num_sponsoring: 0, + num_sponsored: 0, + } as unknown as Horizon.AccountResponse; +} + +const VALID_DISTRIBUTION = [ + { recipient: VALID_RECIPIENT_A, percentage: asPercentage(60) }, + { recipient: VALID_RECIPIENT_B, percentage: asPercentage(40) }, +]; describe('escrow module placeholders', () => { it('exports callable placeholder functions', () => { @@ -14,3 +70,131 @@ describe('escrow module placeholders', () => { }); }); +describe('releaseFunds validation', () => { + it('throws ValidationError for an invalid escrow public key', async () => { + const loadAccount = jest.fn(); + + await expect( + validateReleaseFundsParams( + { + escrowAccountId: 'NOT_A_PUBLIC_KEY', + distribution: [...VALID_DISTRIBUTION], + }, + { loadAccount }, + ), + ).rejects.toBeInstanceOf(ValidationError); + + expect(loadAccount).not.toHaveBeenCalled(); + }); + + it('throws ValidationError for an invalid distribution payload', async () => { + const loadAccount = jest.fn(); + + await expect( + validateReleaseFundsParams( + { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [], + }, + { loadAccount }, + ), + ).rejects.toBeInstanceOf(ValidationError); + + expect(loadAccount).not.toHaveBeenCalled(); + }); + + it('throws EscrowNotFoundError when Horizon cannot find the escrow account', async () => { + const loadAccount = jest.fn().mockRejectedValue( + new NotFoundError('Resource Missing', { status: 404 }), + ); + + await expect( + validateReleaseFundsParams( + { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [...VALID_DISTRIBUTION], + }, + { loadAccount }, + ), + ).rejects.toBeInstanceOf(EscrowNotFoundError); + }); + + it('throws InsufficientBalanceError when the escrow has zero native balance', async () => { + const loadAccount = jest.fn().mockResolvedValue(createMockAccount('0')); + + await expect( + releaseFunds( + { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [...VALID_DISTRIBUTION], + }, + { loadAccount }, + ), + ).rejects.toBeInstanceOf(InsufficientBalanceError); + }); + + it('throws InsufficientBalanceError when the account has no native balance entry', async () => { + const accountWithoutNative = { + ...createMockAccount('25.5'), + balances: [], + } as unknown as Horizon.AccountResponse; + const loadAccount = jest.fn().mockResolvedValue(accountWithoutNative); + + await expect( + releaseFunds( + { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [...VALID_DISTRIBUTION], + }, + { loadAccount }, + ), + ).rejects.toBeInstanceOf(InsufficientBalanceError); + }); + + it('rethrows non-not-found Horizon errors without remapping them', async () => { + const networkError = new Error('horizon unavailable'); + const loadAccount = jest.fn().mockRejectedValue(networkError); + + await expect( + releaseFunds( + { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [...VALID_DISTRIBUTION], + }, + { loadAccount }, + ), + ).rejects.toBe(networkError); + }); + + it('returns the validated Horizon account record on success', async () => { + const account = createMockAccount('25.5'); + const loadAccount = jest.fn().mockResolvedValue(account); + + await expect( + releaseFunds( + { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [...VALID_DISTRIBUTION], + }, + { loadAccount }, + ), + ).resolves.toBe(account); + }); + + it('is idempotent for repeated calls with the same params and account state', async () => { + const account = createMockAccount('25.5'); + const loadAccount = jest.fn().mockResolvedValue(account); + const params = { + escrowAccountId: VALID_ESCROW_ACCOUNT_ID, + distribution: [...VALID_DISTRIBUTION], + }; + + await expect(releaseFunds(params, { loadAccount })).resolves.toBe(account); + await expect(releaseFunds(params, { loadAccount })).resolves.toBe(account); + + expect(loadAccount).toHaveBeenCalledTimes(2); + expect(loadAccount).toHaveBeenNthCalledWith(1, VALID_ESCROW_ACCOUNT_ID); + expect(loadAccount).toHaveBeenNthCalledWith(2, VALID_ESCROW_ACCOUNT_ID); + }); +}); +