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
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ export type { SubmitResult, TransactionStatus } from './types/transaction';

// 6. Standalone functions
export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash } from './escrow';
export { buildMultisigTransaction } from './transactions';
export { buildMultisigTransaction, fetchTransactionOnce, monitorTransaction } from './transactions';
export { getMinimumReserve } from './accounts';
123 changes: 123 additions & 0 deletions src/transactions/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,125 @@
import { TESTNET_HORIZON_URL } from '../utils/constants';
import { MonitorTimeoutError } from '../utils/errors';
import type { TransactionStatus } from '../types/transaction';

export interface MonitorTransactionOptions {
maxAttempts?: number;
intervalMs?: number;
}

interface FetchTransactionFoundResult {
status: 'confirmed' | 'failed';
hash: string;
txLedger: number;
currentLedger: number;
successful: boolean;
}

interface FetchTransactionNotFoundResult {
status: 'not_found';
}

type FetchTransactionResult = FetchTransactionFoundResult | FetchTransactionNotFoundResult;

type HorizonTransactionResponse = {
hash?: unknown;
ledger?: unknown;
successful?: unknown;
currentLedger?: unknown;
latest_ledger?: unknown;
};

function asFiniteNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return value;
}

function toPositiveInt(value: number, fallback: number): number {
if (!Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.floor(value);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

export async function fetchTransactionOnce(txHash: string): Promise<FetchTransactionResult> {
const horizonUrl = process.env.HORIZON_URL ?? TESTNET_HORIZON_URL;
const fetchFn = (globalThis as { fetch?: (input: string) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }> }).fetch;

if (!fetchFn) {
throw new Error('Global fetch is not available in this runtime.');
}

const response = await fetchFn(`${horizonUrl}/transactions/${txHash}`);

if (response.status === 404) {
return { status: 'not_found' };
}

if (!response.ok) {
throw new Error(`Failed to fetch transaction ${txHash}: HTTP ${response.status}`);
}

const payload = (await response.json()) as HorizonTransactionResponse;
const successful = payload.successful === true;
const txLedger = asFiniteNumber(payload.ledger) ?? 0;
const currentLedger =
asFiniteNumber(payload.currentLedger) ??
asFiniteNumber(payload.latest_ledger) ??
txLedger;

return {
status: successful ? 'confirmed' : 'failed',
hash: typeof payload.hash === 'string' ? payload.hash : txHash,
txLedger,
currentLedger,
successful,
};
}

export async function monitorTransaction(
txHash: string,
options: MonitorTransactionOptions = {},
): Promise<TransactionStatus> {
const maxAttempts = toPositiveInt(options.maxAttempts ?? 30, 30);
const intervalMs = toPositiveInt(options.intervalMs ?? 5000, 5000);
let consecutiveNotFound = 0;

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const result = await fetchTransactionOnce(txHash);

if (result.status === 'not_found') {
consecutiveNotFound += 1;

if (attempt >= maxAttempts) {
break;
}

const backoffMultiplier =
consecutiveNotFound > 5 ? 2 ** (consecutiveNotFound - 5) : 1;
await sleep(intervalMs * backoffMultiplier);
continue;
}

const confirmations = Math.max(0, result.currentLedger - result.txLedger);
return {
confirmed: result.status === 'confirmed',
confirmations,
ledger: result.txLedger,
hash: result.hash,
successful: result.successful,
};
}

throw new MonitorTimeoutError(txHash, maxAttempts);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function buildMultisigTransaction(..._args: unknown[]): unknown { return undefined; }
121 changes: 119 additions & 2 deletions tests/unit/transactions/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,125 @@
import { buildMultisigTransaction } from '../../../src/transactions';
import {
buildMultisigTransaction,
monitorTransaction,
} from '../../../src/transactions';
import { MonitorTimeoutError } from '../../../src/utils/errors';

type MockFetchResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};

function createResponse(status: number, payload: unknown): MockFetchResponse {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
};
}

describe('transactions module', () => {
const originalFetch = global.fetch;

afterEach(() => {
jest.useRealTimers();

if (originalFetch) {
global.fetch = originalFetch;
} else {
delete (globalThis as { fetch?: typeof global.fetch }).fetch;
}

jest.restoreAllMocks();
});

describe('transactions module placeholders', () => {
it('exports callable placeholder function', () => {
expect(buildMultisigTransaction()).toBeUndefined();
});

it('returns confirmed status when transaction is found on the 3rd attempt', async () => {
const fetchMock = jest
.fn<Promise<MockFetchResponse>, [string]>()
.mockResolvedValueOnce(createResponse(404, {}))
.mockResolvedValueOnce(createResponse(404, {}))
.mockResolvedValueOnce(
createResponse(200, {
hash: 'tx-success-3rd-attempt',
ledger: 100,
currentLedger: 107,
successful: true,
}),
);

global.fetch = fetchMock as unknown as typeof global.fetch;

const status = await monitorTransaction('tx-success-3rd-attempt', {
maxAttempts: 5,
intervalMs: 1,
});

expect(status).toEqual({
confirmed: true,
confirmations: 7,
ledger: 100,
hash: 'tx-success-3rd-attempt',
successful: true,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it('returns unsuccessful status when Horizon reports failed transaction', async () => {
const fetchMock = jest.fn<Promise<MockFetchResponse>, [string]>().mockResolvedValue(
createResponse(200, {
hash: 'tx-failed',
ledger: 220,
currentLedger: 225,
successful: false,
}),
);

global.fetch = fetchMock as unknown as typeof global.fetch;

const status = await monitorTransaction('tx-failed', {
maxAttempts: 3,
intervalMs: 1,
});

expect(status).toEqual({
confirmed: false,
confirmations: 5,
ledger: 220,
hash: 'tx-failed',
successful: false,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('throws MonitorTimeoutError when max attempts are exceeded', async () => {
jest.useFakeTimers();

const fetchMock = jest
.fn<Promise<MockFetchResponse>, [string]>()
.mockResolvedValue(createResponse(404, {}));

global.fetch = fetchMock as unknown as typeof global.fetch;

const monitorPromise = monitorTransaction('tx-timeout', {
maxAttempts: 3,
intervalMs: 100,
});
const timeoutExpectation = expect(monitorPromise).rejects.toEqual(
expect.objectContaining({
name: MonitorTimeoutError.name,
txHash: 'tx-timeout',
attempts: 3,
}),
);

await jest.advanceTimersByTimeAsync(250);

await timeoutExpectation;
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});