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
180 changes: 180 additions & 0 deletions src/assetIssuerVerifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Asset issuer verification pipeline for StellarSplit.
*
* Verifies that an asset issuer account exists on the network, has a valid
* home domain, publishes a proper stellar.toml, lists the asset code
* in its CURRENCIES section, and is not frozen or deauthorised.
* This prevents fraudulent lookalike assets from being used in invoice payments.
*/

import { Server, StellarTomlResolver } from "@stellar/stellar-sdk";
import type { IssuerVerificationResult } from "./types.js";

/**
* Verify that an asset issuer account is legitimate.
*
* Performs a multi-step verification:
* 1. Loads the issuer account to confirm existence
* 2. Checks the account's home_domain flag
* 3. Checks the account is not frozen or deauthorised (auth_required/revocable check)
* 4. Fetches stellar.toml from the home domain
* 5. Validates the asset code appears in the CURRENCIES section
*
* @param horizonUrl - Full URL of the Horizon API (e.g. "https://horizon.stellar.org").
* @param issuerId - Stellar G-address of the asset issuer.
* @param assetCode - The asset code to verify (e.g. "USDC").
* @param opts - Optional timeout in milliseconds (default: 15_000).
* @returns A detailed {@link IssuerVerificationResult}.
*/
export async function verifyAssetIssuer(
horizonUrl: string,
issuerId: string,
assetCode: string,
opts?: { timeoutMs?: number },
): Promise<IssuerVerificationResult> {
const timeoutMs = opts?.timeoutMs ?? 15_000;
const errors: string[] = [];
let accountExists = false;
let homeDomain: string | null = null;
let tomlFound = false;
let assetInToml = false;
let frozen = false;
let deauthorised = false;

const server = new Server(horizonUrl);

// Step 1: Load the issuer account
let accountRaw: Record<string, unknown> | null = null;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

try {
const account = await server.loadAccount(issuerId);
accountExists = true;

// Extract account data as raw object
accountRaw = account as unknown as Record<string, unknown>;
homeDomain = (accountRaw.home_domain as string) ?? null;

if (!homeDomain) {
errors.push("Issuer account has no home_domain set");
}

// Step 2: Check for frozen/deauthorised flags
const flags = (accountRaw.flags as Record<string, boolean | number>) ?? {};
const authRequired = flags.auth_required === true || flags.auth_required === 1;
const authRevocable = flags.auth_revocable === true || flags.auth_revocable === 1;
const authImmutable = flags.auth_immutable === true || flags.auth_immutable === 1;

if (authImmutable) {
errors.push("Issuer account has auth_immutable flag set — issuer cannot update authorisation");
deauthorised = true;
}
if (authRequired && authRevocable) {
// Both set: issuer can freeze — but this is expected for regulated assets
// Not inherently an error, but worth flagging if no toml validation
}
if (authRequired) {
errors.push("Issuer requires authorisation (auth_required) — may deauthorise trustlines");
deauthorised = true;
}

// Check if account has any frozen trustlines (relevant flag on the issuer)
const authClawbackEnabled = flags.auth_clawback_enabled === true || flags.auth_clawback_enabled === 1;
if (authClawbackEnabled) {
errors.push("Issuer has clawback enabled — may freeze and claw back assets");
frozen = true;
}
} finally {
clearTimeout(timer);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if ((err as { name?: string }).name === "AbortError" || message.includes("abort")) {
errors.push(`Timeout: could not load issuer account within ${timeoutMs}ms`);
} else if (message.includes("Not Found") || message.includes("404")) {
errors.push("Issuer account not found on the network");
} else {
errors.push(`Failed to load issuer account: ${message}`);
}

return {
verified: false,
issuerId,
accountExists: false,
homeDomain: null,
tomlFound: false,
assetInToml: false,
assetCode,
errors,
};
}

// If no home domain, we can't proceed with toml verification
if (!homeDomain) {
return {
verified: false,
issuerId,
accountExists: true,
homeDomain: null,
tomlFound: false,
assetInToml: false,
assetCode,
errors,
};
}

// Step 3: Fetch stellar.toml
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

try {
const toml = await StellarTomlResolver.resolve(homeDomain, {
signal: controller.signal,
} as unknown as { signal: AbortSignal });

tomlFound = true;

// Step 4: Check if the asset code appears in CURRENCIES
const currencies = toml.CURRENCIES ?? [];
if (Array.isArray(currencies)) {
assetInToml = currencies.some(
(c: { code?: string; issuer?: string }) =>
c.code === assetCode && c.issuer === issuerId,
);

if (!assetInToml) {
errors.push(
`Asset code "${assetCode}" with issuer "${issuerId}" not found in stellar.toml CURRENCIES`,
);
}
} else {
errors.push("stellar.toml has no CURRENCIES section");
}
} finally {
clearTimeout(timer);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if ((err as { name?: string }).name === "AbortError" || message.includes("abort")) {
errors.push(`Timeout: could not fetch stellar.toml within ${timeoutMs}ms`);
} else {
errors.push(`Failed to fetch stellar.toml from ${homeDomain}: ${message}`);
}
}

const verified = accountExists && tomlFound && assetInToml && errors.length === 0;

return {
verified,
issuerId,
accountExists,
homeDomain,
tomlFound,
assetInToml,
assetCode,
errors,
};
}
59 changes: 59 additions & 0 deletions src/cursorTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Cursor tracker for persisting Horizon paging tokens.
*
* Provides an in-memory and pluggable storage-backed cursor store so that
* the horizon paginator can resume from the last-seen position across
* restarts or page walks.
*/

import type { CursorStore } from "./types.js";

/**
* In-memory cursor store suitable for session-scoped pagination.
* Cursors are lost when the process exits.
*/
export class InMemoryCursorStore implements CursorStore {
private store = new Map<string, string>();

async save(key: string, cursor: string): Promise<void> {
this.store.set(key, cursor);
}

async load(key: string): Promise<string | null> {
return this.store.get(key) ?? null;
}

async delete(key: string): Promise<void> {
this.store.delete(key);
}

/** Remove all saved cursors. */
clear(): void {
this.store.clear();
}
}

/** Singleton in-memory store shared across the module. */
let defaultStore: CursorStore = new InMemoryCursorStore();

/**
* Override the default cursor store.
* Useful for plugging in localStorage, IndexedDB, or a remote store.
*/
export function setDefaultCursorStore(store: CursorStore): void {
defaultStore = store;
}

/**
* Get the current default cursor store.
*/
export function getDefaultCursorStore(): CursorStore {
return defaultStore;
}

/**
* Build a namespaced cursor key from a base name and namespace.
*/
export function buildCursorKey(namespace: string, name: string): string {
return `${namespace}:${name}`;
}
97 changes: 97 additions & 0 deletions src/horizonPaginator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Horizon collection paginator.
*
* Wraps cursor-based Horizon collection endpoints and exposes an async
* iterator that transparently walks all pages, yielding records until
* the collection is exhausted or a configurable max-records limit is hit.
*
* Integrates with {@link cursorTracker} to persist the last-seen paging
* token for resumable pagination.
*/

import type { CollectionPage, HorizonPaginatorOptions } from "./types.js";
import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js";

/** Default namespace for cursor store keys. */
const DEFAULT_NAMESPACE = "horizon";

/**
* Create an async iterable iterator that walks all pages of a Horizon
* collection endpoint.
*
* @param initialPage - The first page of results (obtained from a Horizon call builder).
* @param opts - Optional configuration for record limits and cursor persistence.
*
* @example
* ```typescript
* const server = new Server("https://horizon.stellar.org");
* const page = await server.payments().forAccount(addr).limit(200).call();
*
* for await (const payment of paginate(page, { maxRecords: 500 })) {
* console.log(payment);
* }
* ```
*/
export async function* paginate<T>(
initialPage: CollectionPage<T>,
opts?: HorizonPaginatorOptions,
): AsyncIterableIterator<T> {
const maxRecords = opts?.maxRecords;
const cursorStore = opts?.cursorStore ?? getDefaultCursorStore();
const namespace = opts?.cursorNamespace ?? DEFAULT_NAMESPACE;

let yielded = 0;
let currentPage: CollectionPage<T> | null = initialPage;

while (currentPage) {
const records = currentPage.records ?? [];
const batch = maxRecords !== undefined
? records.slice(0, maxRecords - yielded)
: records;

for (const record of batch) {
if (maxRecords !== undefined && yielded >= maxRecords) return;
yielded++;
yield record;
}

// Persist the cursor of the last record we just yielded
if (batch.length > 0) {
const lastRecord = batch[batch.length - 1] as unknown;
if (lastRecord && typeof (lastRecord as Record<string, unknown>).paging_token === "string") {
const token = (lastRecord as Record<string, unknown>).paging_token as string;
if (cursorStore) {
const cursorKey = buildCursorKey(namespace, "last");
await cursorStore.save(cursorKey, token).catch(() => {
// Cursor save failures are non-fatal
});
}
}
}

if (maxRecords !== undefined && yielded >= maxRecords) return;

// Fetch next page
currentPage = await currentPage.next();
}
}

/**
* Collect all records from a paginated collection into a single array.
*
* Convenience wrapper around {@link paginate}.
*
* @param initialPage - The first page of results.
* @param opts - Optional configuration.
* @returns All records across all pages (up to maxRecords).
*/
export async function collectAll<T>(
initialPage: CollectionPage<T>,
opts?: HorizonPaginatorOptions,
): Promise<T[]> {
const results: T[] = [];
for await (const record of paginate(initialPage, opts)) {
results.push(record);
}
return results;
}
Loading