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
73 changes: 72 additions & 1 deletion src/escrow/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ContractConfig } from '../types/contract';
import { EscrowParams, EscrowState, SDKResult } from '../types/index';
import { EscrowParams, EscrowState, SDKResult, GetGigsParams, GigsPage } from '../types/index';
import { assertStellarAddress, xlmToStroops } from '../utils/validation';

export class TrustFlowEscrowClient {
Expand Down Expand Up @@ -34,4 +34,75 @@ export class TrustFlowEscrowClient {
async getEscrow(_escrowId: string): Promise<SDKResult<EscrowState | null>> {
return { ok: true, data: null }; // Fetch from contract storage
}

/**
* Returns a paginated list of gigs (escrows) from the TrustFlow backend.
*
* Pagination is cursor-based: each page includes a `nextCursor` value that
* you pass back as `cursor` on the next call to advance through results.
* When `nextCursor` is `null` (or `hasMore` is `false`) you have reached
* the last page.
*
* @param params - Optional filter and pagination parameters
* @param params.cursor - Opaque cursor from a previous response; omit to start from the first page
* @param params.limit - Records per page (default 20, max 100)
* @param params.status - Filter by escrow status
* @param params.depositor:2Filter by depositor address
* @param params.beneficiary - Filter by beneficiary address
*
* @returns `{ ok: true, data: GigsPage }` on success, `{ ok: false, error }` on failure
*
* @example
* ```typescript
* let cursor: string | undefined;
* do {
* const result = await client.getGigs({ cursor, limit: 20, status: 'active' });
* if (!result.ok) { console.error(result.error); break; }
* console.log(result.data.data);
* cursor = result.data.nextCursor ?? undefined;
* } while (cursor);
* ```
*/
async getGigs(params: GetGigsParams = {}): Promise<SDKResult<GigsPage>> {
if (!this.contractConfig.apiBaseUrl) {
return { ok: false, error: 'apiBaseUrl is required to call getGigs' };
}

const query = new URLSearchParams();
if (params.cursor) {
query.set('cursor', params.cursor);
}
if (params.limit) {
query.set('limit', String(Math.min(params.limit, 100)));
}
if (params.status) {
query.set('status', params.status);
}
if (params.depositor) {
query.set('depositor', params.depositor);
}
if (params.beneficiary) {
query.set('beneficiary', params.beneficiary);
}

const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.contractConfig.apiKey) {
headers['Authorization'] = `Bearer ${this.contractConfig.apiKey}`;
}

let res: Response;
try {
res = await fetch(`${this.contractConfig.apiBaseUrl}/gigs?${query}`, { headers });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: `Network error: ${message}` };
}

if (!res.ok) {
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
}

const json = await res.json();
return { ok: true, data: json as GigsPage };
}
}
2 changes: 2 additions & 0 deletions src/types/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export interface ContractConfig {
network: 'TESTNET' | 'MAINNET';
rpcUrl: string;
networkPassphrase: string;
apiBaseUrl?: string;
apiKey?: string;
}

export interface InvokeContractParams {
Expand Down
32 changes: 32 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,35 @@ export interface DisputeParams {
}

export type SDKResult<T> = { ok: true; data: T } | { ok: false; error: string };

/**
* Parameters for paginated gig listing. All fields are optional —
* omitting `cursor` starts from the most recent page.
*/
export interface GetGigsParams {
/** Opaque cursor returned by a previous `getGigs` call to fetch the next page. */
cursor?: string;
/** Maximum records to return per page. Capped at 100 by the backend. Defaults to 20. */
limit?: number;
/** Filter by escrow status. */
status?: EscrowState['status'];
/** Filter by depositor Stellar address. */
depositor?: StellarAddress;
/** Filter by beneficiary Stellar address. */
beneficiary?: StellarAddress;
}

/**
* A single page of gigs returned by `getGigs`.
*/
export interface GigsPage {
/** Gig records for this page. */
data: EscrowState[];
/**
* Cursor to pass as `cursor` on the next `getGigs` call.
* `null` when this is the last page.
*/
nextCursor: string | null;
/** Whether another page exists after this one. */
hasMore: boolean;
}
152 changes: 152 additions & 0 deletions tests/gigs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { TrustFlowEscrowClient } from '../src/escrow/client';
import type { GigsPage } from '../src/types/index';

const BASE_CONTRACT_CONFIG = {
contractId: 'C' + 'A'.repeat(55),
network: 'TESTNET' as const,
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
};

const API_BASE = 'https://api.trustflow.xyz';
const API_KEY = 'test-api-key';

const makePage = (overrides: Partial<GigsPage> = {}): GigsPage => ({
data: [],
nextCursor: null,
hasMore: false,
...overrides,
});

describe('TrustFlowEscrowClient.getGigs', () => {
let fetchSpy: jest.SpyInstance;

beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});

afterEach(() => {
fetchSpy.mockRestore();
});

it('returns an error when apiBaseUrl is not configured', async () => {
const client = new TrustFlowEscrowClient(BASE_CONTRACT_CONFIG);
const result = await client.getGigs();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/apiBaseUrl/);
}
});

it('returns an empty first page when no gigs exist', async () => {
const page = makePage();
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(page), { status: 200 }));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE, apiKey: API_KEY });
const result = await client.getGigs({ limit: 20 });

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toHaveLength(0);
expect(result.data.nextCursor).toBeNull();
expect(result.data.hasMore).toBe(false);
}
});

it('sends cursor, limit, status, depositor and beneficiary as query params', async () => {
const page = makePage();
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(page), { status: 200 }));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE });
await client.getGigs({
cursor: 'abc123',
limit: 10,
status: 'active',
depositor: 'G' + 'A'.repeat(55),
beneficiary: 'G' + 'B'.repeat(55),
});

const calledUrl = new URL((fetchSpy.mock.calls[0][0] as string));
expect(calledUrl.searchParams.get('cursor')).toBe('abc123');
expect(calledUrl.searchParams.get('limit')).toBe('10');
expect(calledUrl.searchParams.get('status')).toBe('active');
expect(calledUrl.searchParams.get('depositor')).toBe('G' + 'A'.repeat(55));
expect(calledUrl.searchParams.get('beneficiary')).toBe('G' + 'B'.repeat(55));
});

it('caps limit at 100 regardless of what the caller passes', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(makePage()), { status: 200 }));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE });
await client.getGigs({ limit: 500 });

const calledUrl = new URL((fetchSpy.mock.calls[0][0] as string));
expect(calledUrl.searchParams.get('limit')).toBe('100');
});

it('sends Authorization header when apiKey is configured', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(makePage()), { status: 200 }));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE, apiKey: API_KEY });
await client.getGigs();

const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
expect(headers['Authorization']).toBe(`Bearer ${API_KEY}`);
});

it('traverses multiple pages using nextCursor', async () => {
const ADDR_A = 'G' + 'A'.repeat(55);
const page1: GigsPage = {
data: [{ id: 'esc-1', params: { depositor: ADDR_A, beneficiary: ADDR_A, amountXLM: '10' }, status: 'active', createdAt: 1 }],
nextCursor: 'cursor-page-2',
hasMore: true,
};
const page2: GigsPage = {
data: [{ id: 'esc-2', params: { depositor: ADDR_A, beneficiary: ADDR_A, amountXLM: '20' }, status: 'pending', createdAt: 2 }],
nextCursor: null,
hasMore: false,
};

fetchSpy
.mockResolvedValueOnce(new Response(JSON.stringify(page1), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(page2), { status: 200 }));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE });

const result1 = await client.getGigs();
expect(result1.ok).toBe(true);
if (!result1.ok) return;
expect(result1.data.nextCursor).toBe('cursor-page-2');

const result2 = await client.getGigs({ cursor: result1.data.nextCursor! });
expect(result2.ok).toBe(true);
if (!result2.ok) return;
expect(result2.data.nextCursor).toBeNull();
expect(result2.data.hasMore).toBe(false);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it('returns an error on non-2xx HTTP response', async () => {
fetchSpy.mockResolvedValueOnce(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE });
const result = await client.getGigs();

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/401/);
}
});

it('returns a network error when fetch throws', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch'));

const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE });
const result = await client.getGigs();

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/Network error/);
}
});
});
Loading