Skip to content
Open

done #96

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
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 77 additions & 0 deletions src/escrow/index.ts
Original file line number Diff line number Diff line change
@@ -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<Horizon.AccountResponse>;
}

function createDefaultLoadAccount(
): (escrowAccountId: string) => Promise<Horizon.AccountResponse> {
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<Horizon.AccountResponse> {
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<Horizon.AccountResponse> {
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
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
184 changes: 184 additions & 0 deletions tests/unit/escrow/index.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
});
});