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
15 changes: 14 additions & 1 deletion src/batch-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import {
BASE_FEE,
} from '@stellar/stellar-sdk';
import { NETWORK_PASSPHRASE, createRpcServer } from './soroban.js';
import { RateLimitError } from './errors.js';
import { RateLimitError, BatchPartiallySubmittedError } from './errors.js';
export { BatchPartiallySubmittedError } from './errors.js';
import type { Network } from './types/index.js';

export const DEFAULT_BATCH_TIMEOUT_SECONDS = 30;
Expand Down Expand Up @@ -433,6 +434,11 @@ export interface BatchSubmitOptions {
* terminal state (SUCCESS, FAILED, SKIPPED, or ERROR).
*/
onProgress?: (progress: { index: number; method: string; status: BatchTxStatus }) => void;
/**
* If true, throws a typed BatchPartiallySubmittedError carrying firstFailureIndex
* and skippedIndices when a transaction in the batch fails or is skipped.
*/
throwOnPartial?: boolean;
}

const DEFAULT_SUBMIT_POLL_INTERVAL_MS = 1_000;
Expand Down Expand Up @@ -590,6 +596,13 @@ export async function submitBatch(
}
}

if (options.throwOnPartial && firstFailureIndex !== -1) {
const skippedIndices = outcomes
.filter(o => o.status === 'SKIPPED')
.map(o => o.index);
throw new BatchPartiallySubmittedError(firstFailureIndex, skippedIndices, outcomes);
}

return {
allSucceeded: firstFailureIndex === -1,
firstFailureIndex,
Expand Down
27 changes: 27 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,31 @@ export class OperationAbortedError extends Error {
}
}

/**
* Error thrown or exposed when a sequential batch submission encounters a mid-batch failure.
* Carries the index of the first failed transaction, the indices of subsequent
* skipped transactions, and all completed/skipped outcomes.
*/
export class BatchPartiallySubmittedError extends Error {
public readonly firstFailureIndex: number;
public readonly skippedIndices: number[];
public readonly outcomes: unknown[];

constructor(
firstFailureIndex: number,
skippedIndices: number[],
outcomes: unknown[] = [],
) {
super(
`Batch partially submitted: transaction at index ${firstFailureIndex} failed; skipped subsequent index(es): [${skippedIndices.join(', ')}]`,
);
this.name = 'BatchPartiallySubmittedError';
this.firstFailureIndex = firstFailureIndex;
this.skippedIndices = skippedIndices;
this.outcomes = outcomes;
}
}

// ── Type guard ────────────────────────────────────────────────────────────────

/**
Expand All @@ -506,6 +531,7 @@ export class OperationAbortedError extends Error {
* - {@link RpcServiceUnavailableError}
* - {@link IndexerTimeoutError}
* - {@link OperationAbortedError}
* - {@link BatchPartiallySubmittedError}
*/
export function isConduitError(value: unknown): value is Error {
if (!(value instanceof Error)) return false;
Expand All @@ -518,5 +544,6 @@ export function isConduitError(value: unknown): value is Error {
'RpcServiceUnavailableError',
'IndexerTimeoutError',
'OperationAbortedError',
'BatchPartiallySubmittedError',
].includes(value.name);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
RpcServiceUnavailableError,
IndexerTimeoutError,
OperationAbortedError,
BatchPartiallySubmittedError,
isConduitError,
SUPPORTED_NETWORKS,
CAIP2_TO_NETWORK,
Expand Down
46 changes: 46 additions & 0 deletions src/tests/batch-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock('@stellar/stellar-sdk', async () => {
import {
submitBatch,
BatchBuildError,
BatchPartiallySubmittedError,
type BuiltBatchTransaction,
type BatchSubmitOptions,
} from '../batch-tx.js';
Expand Down Expand Up @@ -400,4 +401,49 @@ describe('submitBatch — abort signal', () => {
expect(result.outcomes[1]!.status).toBe('SKIPPED');
expect(result.outcomes[2]!.status).toBe('SKIPPED');
});

describe('BatchPartiallySubmittedError (#601)', () => {
it('throws BatchPartiallySubmittedError when throwOnPartial is true and tx fails at submission', async () => {
mockSendTransaction
.mockResolvedValueOnce(sendOk(0))
.mockResolvedValueOnce({ status: 'ERROR', errorResult: 'txBAD_AUTH' });
mockGetTransaction.mockResolvedValueOnce(statusOk());

const txs = [makeTx(0), makeTx(1), makeTx(2), makeTx(3)];
await expect(
submitBatch(txs, RPC_URL, { ...OPTS, throwOnPartial: true }),
).rejects.toThrow(BatchPartiallySubmittedError);

mockSendTransaction.mockReset();
mockGetTransaction.mockReset();
mockSendTransaction
.mockResolvedValueOnce(sendOk(0))
.mockResolvedValueOnce({ status: 'ERROR', errorResult: 'txBAD_AUTH' });
mockGetTransaction.mockResolvedValueOnce(statusOk());

try {
await submitBatch(txs, RPC_URL, { ...OPTS, throwOnPartial: true });
expect.unreachable('Should have thrown BatchPartiallySubmittedError');
} catch (err) {
expect(err).toBeInstanceOf(BatchPartiallySubmittedError);
const partialErr = err as BatchPartiallySubmittedError;
expect(partialErr.firstFailureIndex).toBe(1);
expect(partialErr.skippedIndices).toEqual([2, 3]);
expect(partialErr.outcomes).toHaveLength(4);
expect(partialErr.name).toBe('BatchPartiallySubmittedError');
}
});

it('does not throw when throwOnPartial is false or omitted on failure', async () => {
mockSendTransaction
.mockResolvedValueOnce(sendOk(0))
.mockResolvedValueOnce({ status: 'ERROR', errorResult: 'txBAD_AUTH' });
mockGetTransaction.mockResolvedValueOnce(statusOk());

const txs = [makeTx(0), makeTx(1), makeTx(2)];
const result = await submitBatch(txs, RPC_URL, OPTS);
expect(result.allSucceeded).toBe(false);
expect(result.firstFailureIndex).toBe(1);
});
});
});