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
89 changes: 89 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ import { computePrediction } from "./predictor.js";
import type { CompletionPrediction } from "./predictor.js";
import { PriorityQueue } from "./priorityQueue.js";
import type { RequestPriority } from "./priorityQueue.js";
import { HorizonFallbackReader } from "./horizonFallback.js";
import type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js";
import { FallbackChain } from "./fallbackChain.js";

/** A plugin that extends StellarSplitClient with new methods at runtime. */
export interface StellarSplitPlugin {
Expand Down Expand Up @@ -140,6 +143,12 @@ export interface StellarSplitClientConfig {
hooks?: InvoiceLifecycleHooks;
/** Optional adaptive retry configuration. When provided, replaces legacy maxRetries for pay/cloneInvoice. */
retry?: RetryConfig;
/**
* Optional Horizon API base URL (e.g. "https://horizon.stellar.org").
* When provided, read-only account lookups fall back to Horizon automatically
* if the primary Soroban RPC endpoint throws or times out.
*/
horizonUrl?: string;
}

/** Network configuration. */
Expand Down Expand Up @@ -186,6 +195,7 @@ export class StellarSplitClient {
private _adapter: WalletAdapter | null = null;
private _hooks: InvoiceLifecycleHooks = {};
private _retryEngine: RetryEngine | null = null;
private _horizonReader: HorizonFallbackReader | null = null;

private get server(): SorobanRpc.Server {
return this._rpcClient ?? this._standby?.server ?? this._mainServer;
Expand Down Expand Up @@ -304,6 +314,10 @@ export class StellarSplitClient {
this._retryEngine = new RetryEngine(config.retry, new TelemetryCollector());
}

if (config.horizonUrl) {
this._horizonReader = new HorizonFallbackReader(config.horizonUrl);
}

initHealthDashboard(this.server, this._dedup);
}

Expand Down Expand Up @@ -2077,6 +2091,81 @@ export class StellarSplitClient {
return chain;
}

// ---------------------------------------------------------------------------
// Issue #198 — Horizon fallback for read-only account operations
// ---------------------------------------------------------------------------

/**
* Fetch normalised account info (id + sequence number).
*
* Tries the Soroban RPC endpoint first. If `horizonUrl` was supplied in
* the config and the RPC call throws, the request is automatically retried
* against the Horizon REST API via a two-link FallbackChain.
*
* @param address - Stellar public key of the account.
*/
async getAccount(address: string): Promise<NormalizedAccount> {
const rpcFetch = async (): Promise<NormalizedAccount> => {
const acc = await this.server.getAccount(address);
return { id: acc.accountId(), sequence: acc.sequenceNumber() };
};

if (!this._horizonReader) {
return rpcFetch();
}

const horizonReader = this._horizonReader;
const chain = new FallbackChain(["rpc", "horizon"], {
logger: (attempt) =>
console.warn(
`[StellarSplitClient] getAccount fallback (${attempt.url}): ${attempt.error}`
),
});

return chain.execute(async (provider) => {
if (provider === "rpc") return rpcFetch();
return horizonReader.getAccount(address);
});
}

/**
* Fetch all balances for `address`.
*
* Balance data is not exposed by the Soroban RPC protocol, so this always
* reads from the Horizon API. A two-link FallbackChain is used so that if
* `horizonUrl` is absent the call fails fast with a clear message.
*
* Requires `horizonUrl` to be set in the client config.
*
* @param address - Stellar public key of the account.
* @throws If no `horizonUrl` was configured.
*/
async getAccountBalances(address: string): Promise<NormalizedBalance[]> {
if (!this._horizonReader) {
throw new Error(
"getAccountBalances requires horizonUrl to be set in StellarSplitClientConfig"
);
}

const horizonReader = this._horizonReader;
// Soroban RPC has no balance endpoint — the chain falls through to Horizon immediately.
const chain = new FallbackChain(["rpc", "horizon"], {
logger: (attempt) =>
console.warn(
`[StellarSplitClient] getAccountBalances fallback (${attempt.url}): ${attempt.error}`
),
});

return chain.execute(async (provider) => {
if (provider === "rpc") {
throw new Error(
"Soroban RPC does not expose account balances; delegating to Horizon"
);
}
return horizonReader.getAccountBalances(address);
});
}

// ---------------------------------------------------------------------------
// Issue #73 — syncInvoice (cross-network)
// ---------------------------------------------------------------------------
Expand Down
81 changes: 81 additions & 0 deletions src/horizonFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Horizon API fallback reader for StellarSplitClient.
*
* Wraps @stellar/stellar-sdk's Horizon.Server to provide normalised
* getAccount / getAccountBalances reads that are compatible with the
* shapes returned by the Soroban RPC path. Used as the second link in
* a FallbackChain when the primary Soroban RPC endpoint is unavailable.
*/

import { Horizon } from "@stellar/stellar-sdk";

// ---------------------------------------------------------------------------
// Normalised types shared between the RPC path and the Horizon path
// ---------------------------------------------------------------------------

/** Minimal account info — mirrors what Soroban RPC's getAccount returns. */
export interface NormalizedAccount {
/** Stellar public key (G…). */
id: string;
/** Current sequence number as a decimal string. */
sequence: string;
}

/**
* Single balance entry normalised across native / issued asset types.
*
* - Native XLM: `asset === "native"`
* - Issued asset: `asset === "CODE:ISSUER"`
*/
export interface NormalizedBalance {
asset: string;
balance: string;
}

// ---------------------------------------------------------------------------
// HorizonFallbackReader
// ---------------------------------------------------------------------------

/**
* Read-only Horizon API client that normalises account and balance responses
* into shapes compatible with the rest of the StellarSplit SDK.
*
* Instantiate once and reuse; Horizon.Server manages its own connection pool.
*/
export class HorizonFallbackReader {
private readonly _server: Horizon.Server;

constructor(horizonUrl: string) {
this._server = new Horizon.Server(horizonUrl);
}

/**
* Fetch account info from Horizon and return a normalised account object.
*
* @param address - Stellar public key of the account to look up.
*/
async getAccount(address: string): Promise<NormalizedAccount> {
const response = await this._server.loadAccount(address);
return {
id: response.id,
sequence: response.sequenceNumber(),
};
}

/**
* Fetch all balances for `address` from Horizon and return them in a
* normalised format.
*
* @param address - Stellar public key of the account.
*/
async getAccountBalances(address: string): Promise<NormalizedBalance[]> {
const response = await this._server.loadAccount(address);
return response.balances.map((b) => {
const asset =
b.asset_type === "native"
? "native"
: `${"asset_code" in b ? b.asset_code : ""}:${"asset_issuer" in b ? b.asset_issuer : ""}`;
return { asset, balance: b.balance };
});
}
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,9 @@ export type {
SimulationDiffNotComparable,
ResourceDelta,
} from "./simulationDiff.js";

export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js";
export type { Sep41TokenCapabilities } from "./sep41Adapter.js";

export { HorizonFallbackReader } from "./horizonFallback.js";
export type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js";
Loading