Skip to content
Merged
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
135 changes: 135 additions & 0 deletions docs/sdk_errors_and_replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# ILN SDK Error Handling & Event Replay

This document details the refined error handling layer and the historical event replay/catch-up system added to the Invoice Liquidity Network (ILN) TypeScript SDK.

---

## 1. SDK Error Handling

The SDK maps low-level Soroban contract simulation and transaction execution errors into a structured hierarchy of typed classes, allowing applications to catch specific classes of errors and respond dynamically.

### Error Hierarchy

All SDK errors inherit from the base `ILNError` class:

* **`ILNError`** (extends `Error`)
* **`ValidationError`** — Raised when input arguments or values violate validation bounds.
* `InvoiceNotFound`
* `InvalidAmount`
* `InvalidDiscountRate`
* `InvalidDueDate`
* `DueDateTooSoon`
* `DueDateTooFar`
* `SelfInvoice`
* `AmountTooSmall`
* `InvalidAddress`
* `BatchTooLarge`
* **`AuthorizationError`** — Raised when the caller does not have sufficient permissions.
* `Unauthorized`
* `NotApprovedFunder`
* `PayerUnverified`
* **`InvoiceStateError`** — Raised when an invalid state transition or action is performed.
* `AlreadyFunded`
* `AlreadyPaid`
* `NotFunded`
* `InvoiceDefaulted`
* `NothingToClaim`
* `NotYetDefaulted`
* `OverfundingRejected`
* `InvoiceExpired`
* `AlreadyCancelled`
* `AlreadyInitialized`
* `AlreadyAppealed`
* `AppealWindowClosed`
* `NotDefaulted`
* `AlreadyInQueue`
* `InvoiceAppealed`
* `AlreadyDisputed`
* `NotDisputed`
* `InvoiceDisputed`
* `OverpaymentRejected`
* `PayerReputationTooLow`
* `InvoiceNotCancellable`
* **`ContractExecutionError`** — Raised during contract execution failures.
* `ContractPaused`
* `ArithmeticOverflow`
* `FeeOnTransferToken`
* `OracleDataStale`
* `InvalidTransfer`
* `InsufficientAmount`
* **`NetworkError`** — Raised during transient network, RPC connectivity, or connection abort failures.

### Context Metadata

Every error instance exposes the following diagnostic properties where available:

* `txHash?: string` — The transaction hash in which the error occurred.
* `ledger?: number` — The ledger sequence number.
* `contractId?: string` — The target contract ID.
* `originalCode?: number` — The original integer error code returned from the contract.

### Retry Guidance

Transient errors expose a `recommendRetry` boolean flag:

* **`recommendRetry: true`** is returned for transient errors, such as:
* Network timeouts / DNS failures
* Rate limiting / HTTP 429
* Temporary server unavailability / HTTP 503
* Ledger synchronization delays
* **`recommendRetry: false`** is returned for permanent failures (e.g. invalid arguments, unauthorized calls).

---

## 2. Event Replay & Gap Recovery

To maintain high data consistency and reliability for client applications, indexing services, and dashboards, the SDK supports historical event replay and automatic ledger gap recovery.

### Replaying Historical Events

The `replay` function allows querying contract events backwards or forwards from a specific ledger sequence:

```typescript
import { replay } from "@iln/sdk";

const finalCursor = await replay(
horizon,
CONTRACT_ID,
{ fromLedger: 1234500 }, // event filters
1234500, // starting ledger
(event) => {
console.log("Replayed historical event:", event);
}
);
```

### Event Subscription with Replay

Pass the `fromLedger` property inside the filter parameter to `subscribe` to seamlessly replay history before starting the live SSE event stream:

```typescript
import { subscribe } from "@iln/sdk";

const unsubscribe = subscribe(
horizon,
CONTRACT_ID,
{ fromLedger: 1234500, types: ["funded"] },
(event) => {
console.log("Processed event:", event);
},
(err) => {
console.error("Subscription stream error:", err);
}
);

// Stop the subscription later
unsubscribe();
```

### Ledger Gap Recovery & Deduplication

During real-time streaming, network interruptions, indexer restarts, or connection lag can lead to missing events. The `subscribe` logic actively guards against this:

1. **Gap Detection**: Whenever a new live event is received, the subscriber checks if its ledger sequence is greater than the next expected ledger sequence (`event.ledger > lastProcessedLedger + 1`).
2. **Recovery**: If a gap is detected, the subscriber triggers an asynchronous `replay` starting from the missing ledger sequence up to the current event.
3. **Deduplication**: A slide-capped cache of processed paging tokens is maintained. Replayed events that have already been processed (or duplicates received from the SSE stream) are automatically ignored.
70 changes: 70 additions & 0 deletions sdk/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import {
ILNError,
ValidationError,
AuthorizationError,
InvoiceStateError,
ContractExecutionError,
NetworkError,
} from "./errors.js";

describe("SDK Error Handling", () => {
describe("fromError mapping", () => {
it("maps contract error code 1 to ValidationError.InvoiceNotFound", () => {
const err = ILNError.fromError("Error(Contract, 1)");
expect(err).toBeInstanceOf(ILNError.InvoiceNotFound);
expect(err).toBeInstanceOf(ValidationError);
expect((err as ILNError).originalCode).toBe(1);
expect((err as ILNError).recommendRetry).toBe(false);
});

it("maps contract error code 2 to InvoiceStateError.AlreadyFunded", () => {
const err = ILNError.fromError("Error(Contract, 2)");
expect(err).toBeInstanceOf(ILNError.AlreadyFunded);
expect(err).toBeInstanceOf(InvoiceStateError);
expect((err as ILNError).originalCode).toBe(2);
expect((err as ILNError).recommendRetry).toBe(false);
});

it("maps contract error code 5 to AuthorizationError.Unauthorized", () => {
const err = ILNError.fromError("Error(Contract, 5)");
expect(err).toBeInstanceOf(ILNError.Unauthorized);
expect(err).toBeInstanceOf(AuthorizationError);
expect((err as ILNError).originalCode).toBe(5);
expect((err as ILNError).recommendRetry).toBe(false);
});

it("maps contract error code 26 to ContractExecutionError.ContractPaused", () => {
const err = ILNError.fromError("Error(Contract, 26)");
expect(err).toBeInstanceOf(ILNError.ContractPaused);
expect(err).toBeInstanceOf(ContractExecutionError);
expect((err as ILNError).originalCode).toBe(26);
expect((err as ILNError).recommendRetry).toBe(false);
});

it("maps network timeout to NetworkError with retry recommendation", () => {
const err = ILNError.fromError("Request timeout on endpoint");
expect(err).toBeInstanceOf(NetworkError);
expect((err as ILNError).recommendRetry).toBe(true);
});

it("maps ledger synchronization delay to ContractExecutionError with retry recommendation", () => {
const err = ILNError.fromError("Ledger synchronization delay detected");
expect(err).toBeInstanceOf(ContractExecutionError);
expect((err as ILNError).recommendRetry).toBe(true);
});

it("preserves context metadata in the mapped error", () => {
const context = {
txHash: "0x123",
ledger: 456,
contractId: "C123",
};
const err = ILNError.fromError("Error(Contract, 1)", context) as ILNError;
expect(err.txHash).toBe("0x123");
expect(err.ledger).toBe(456);
expect(err.contractId).toBe("C123");
expect(err.originalCode).toBe(1);
});
});
});
Loading
Loading