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
29 changes: 29 additions & 0 deletions sdk/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AccountSignersResponse,
AccountAgeResponse,
AccountRiskScoreResponse,
AccountOffersResponse,
TrustlineEntry,
PaymentOperation,
} from "../types/index.d";
Expand Down Expand Up @@ -141,6 +142,34 @@ export class AccountModule {
return this._get<PaginatedResponse<PaymentOperation>>(path);
}

/**
* Get all open offers for a Stellar account with pagination support.
*
* @param id - Stellar account public key.
* @param options - Optional pagination options.
* @param options.limit - Maximum number of records to return.
* @param options.order - Sort order ("asc" or "desc", default "desc").
* @param options.cursor - Pagination cursor from a previous response.
* @returns Resolves to a paginated response containing open offers.
* @throws {StellarKitError} On non-2xx response.
*
* @example
* const offers = await account.getOffers("GAAZI4...");
* const page2 = await account.getOffers("GAAZI4...", { limit: 10, cursor: "12345" });
*/
async getOffers(
id: string,
options?: { limit?: number; order?: string; cursor?: string },
): Promise<AccountOffersResponse["data"]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.order) params.set("order", options.order);
if (options?.cursor) params.set("cursor", options.cursor);
const query = params.toString();
const path = `/account/${id}/offers${query ? `?${query}` : ""}`;
return this._get<AccountOffersResponse["data"]>(path);
}

/**
* Get the signers and threshold configuration for an account.
*
Expand Down
14 changes: 14 additions & 0 deletions sdk/stellarkit-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,20 @@ class StellarKitClient {
return this._request(`/account/${accountId}/subentry-health`);
}

/**
* Get all open offers for an account on the Stellar DEX.
*
* @param {string} accountId - Stellar account public key
* @param {Object} [options] - Pagination options
* @param {number} [options.limit=10] - Number of records
* @param {string} [options.order='desc'] - Sort order
* @param {string} [options.cursor] - Pagination cursor
* @returns {Promise<Object>} Paginated offers response
*/
async getAccountOffers(accountId, { limit, order, cursor } = {}) {
return this._request(`/account/${accountId}/offers`, { params: { limit, order, cursor } });
}

/**
* Get a consolidated summary of account info, recent transactions, and open offers.
*
Expand Down
27 changes: 6 additions & 21 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,6 @@ const { Asset } = require("@stellar/stellar-sdk");
const { getAssetMetadataFromToml } = require("../utils/tomlResolver");
const { formatBalance } = require("../utils/formatBalance");

function validateLimit(limit, max = 200) {
const n = Number(limit);
if (!Number.isInteger(n) || n <= 0 || n > max) {
const err = new Error(`limit must be an integer between 1 and ${max}`);
err.status = 400;
err.field = "limit";
err.receivedValue = String(limit);
throw err;
}
return n;
}

function handleAccountNotFound(err, next, accountId) {
if (err && err.response && err.response.status === 404) {
return next(makeAccountNotFoundError(accountId, NETWORK));
Expand Down Expand Up @@ -283,11 +271,9 @@ router.get("/:id/offers", async (req, res, next) => {
try {
const { id } = req.params;
validateAccountId(id);
const { limit, order, cursor } = parsePaginationParams(req.query, 200);

const limit = validateLimit(req.query.limit || 10, 200);
const cursor = req.query.cursor || undefined;

let query = server.offers().forAccount(id).limit(limit);
let query = server.offers().forAccount(id).limit(limit).order(order);
if (cursor) query = query.cursor(cursor);

const offerResponse = await query.call();
Expand All @@ -300,7 +286,7 @@ router.get("/:id/offers", async (req, res, next) => {
};

return {
id: offer.id,
offerId: offer.id,
selling: {
...buildAsset(
offer.selling_asset_type,
Expand All @@ -319,13 +305,12 @@ router.get("/:id/offers", async (req, res, next) => {
};
});

const hasMore = (offerResponse.records || []).length === limit;
const nextCursor = hasMore
? (offerResponse.records[offerResponse.records.length - 1] || {}).paging_token
const nextCursor = offers.length > 0
? offerResponse.records[offerResponse.records.length - 1].paging_token
: null;

return success(res, {
items: offers,
offers,
total: offers.length,
limit,
cursor: nextCursor,
Expand Down
163 changes: 163 additions & 0 deletions tests/account.offers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
const request = require("supertest");
const app = require("../src/index");
const { server } = require("../src/config/stellar");
const { Keypair } = require("@stellar/stellar-sdk");

jest.mock("../src/config/stellar", () => {
const originalModule = jest.requireActual("../src/config/stellar");
return {
...originalModule,
server: {
offers: jest.fn(),
},
};
});

describe("GET /account/:id/offers", () => {
const accountId = Keypair.random().publicKey();

beforeEach(() => {
jest.clearAllMocks();
});

it("returns open offers for a valid account", async () => {
const mockRecords = [
{
id: "456",
selling_asset_type: "native",
buying_asset_type: "credit_alphanum4",
buying_asset_code: "USDC",
buying_asset_issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
amount: "50.0",
price: "2.5",
last_modified_ledger: 12345,
paging_token: "p1",
},
];

server.offers.mockReturnValue({
forAccount: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
cursor: jest.fn().mockReturnThis(),
call: jest.fn().mockResolvedValue({ records: mockRecords }),
});

const res = await request(app).get(`/account/${accountId}/offers`);

expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.offers).toHaveLength(1);
expect(res.body.data.total).toBe(1);
expect(res.body.data).toHaveProperty("limit");
expect(res.body.data).toHaveProperty("cursor");

const offer = res.body.data.offers[0];
expect(offer).toHaveProperty("offerId", "456");
expect(offer.selling).toMatchObject({
assetType: "native",
assetCode: "XLM",
assetIssuer: null,
amount: "50.0",
});
expect(offer.buying).toMatchObject({
assetType: "credit_alphanum4",
assetCode: "USDC",
assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
});
expect(offer).toHaveProperty("price", "2.5");
expect(offer).toHaveProperty("lastModifiedLedger", 12345);
});

it("returns empty offers array when account has no offers", async () => {
server.offers.mockReturnValue({
forAccount: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
call: jest.fn().mockResolvedValue({ records: [] }),
});

const res = await request(app).get(`/account/${accountId}/offers`);

expect(res.statusCode).toBe(200);
expect(res.body.data.offers).toEqual([]);
expect(res.body.data.total).toBe(0);
expect(res.body.data.cursor).toBeNull();
});

it("respects limit query param", async () => {
server.offers.mockReturnValue({
forAccount: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
cursor: jest.fn().mockReturnThis(),
call: jest.fn().mockResolvedValue({ records: [] }),
});

const res = await request(app).get(`/account/${accountId}/offers?limit=5`);

expect(res.statusCode).toBe(200);
expect(server.offers).toHaveBeenCalled();
});

it("passes order param to Horizon", async () => {
server.offers.mockReturnValue({
forAccount: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
cursor: jest.fn().mockReturnThis(),
call: jest.fn().mockResolvedValue({ records: [] }),
});

await request(app).get(`/account/${accountId}/offers?order=asc`);

expect(server.offers).toHaveBeenCalled();
});

it("returns 400 for invalid account ID", async () => {
const res = await request(app).get("/account/INVALID_KEY/offers");

expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.type).toBe("ValidationError");
});

it("returns 400 for invalid limit value", async () => {
const res = await request(app).get(`/account/${accountId}/offers?limit=-1`);

expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.type).toBe("ValidationError");
});

it("returns cursor when more results exist", async () => {
const mockRecords = [
{ id: "1", paging_token: "p1", amount: "10", price: "1", last_modified_ledger: 1 },
{ id: "2", paging_token: "p2", amount: "20", price: "2", last_modified_ledger: 2 },
];
const enriched = mockRecords.map((r) => ({
...r,
selling_asset_type: "native",
buying_asset_type: "native",
selling_asset_code: undefined,
selling_asset_issuer: undefined,
buying_asset_code: undefined,
buying_asset_issuer: undefined,
last_modified_ledger: r.last_modified_ledger,
}));

server.offers.mockReturnValue({
forAccount: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
cursor: jest.fn().mockReturnThis(),
call: jest.fn().mockResolvedValue({ records: enriched }),
});

const res = await request(app).get(`/account/${accountId}/offers?limit=2`);

expect(res.statusCode).toBe(200);
expect(res.body.data.cursor).toBe("p2");
expect(res.body.data.offers).toHaveLength(2);
});
});
38 changes: 32 additions & 6 deletions tests/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -660,15 +660,15 @@ image = "https://example.com/test.png"

expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("items");
expect(res.body.data.items).toBeInstanceOf(Array);
expect(res.body.data).toHaveProperty("offers");
expect(res.body.data.offers).toBeInstanceOf(Array);
expect(res.body.data).toHaveProperty("total");
expect(res.body.data).toHaveProperty("limit");
expect(res.body.data).toHaveProperty("cursor");

if (res.body.data.items.length > 0) {
const offer = res.body.data.items[0];
expect(offer).toHaveProperty("id");
if (res.body.data.offers.length > 0) {
const offer = res.body.data.offers[0];
expect(offer).toHaveProperty("offerId");
expect(offer).toHaveProperty("selling");
expect(offer).toHaveProperty("buying");
expect(offer).toHaveProperty("price");
Expand All @@ -691,7 +691,33 @@ image = "https://example.com/test.png"
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.limit).toBe(1);
expect(res.body.data.items.length).toBeLessThanOrEqual(1);
expect(res.body.data.offers.length).toBeLessThanOrEqual(1);
});

it("respects cursor query param", async () => {
const first = await request(app).get(
`/account/${VALID_KEY}/offers?limit=1`
);

expect(first.statusCode).toBe(200);

if (first.body.data.cursor) {
const second = await request(app).get(
`/account/${VALID_KEY}/offers?limit=1&cursor=${first.body.data.cursor}`
);

expect(second.statusCode).toBe(200);
expect(second.body.data.offers).toBeInstanceOf(Array);
}
});

it("respects order query param", async () => {
const res = await request(app).get(
`/account/${VALID_KEY}/offers?order=asc`
);

expect(res.statusCode).toBe(200);
expect(res.body.data.offers).toBeInstanceOf(Array);
});

it("returns 400 for invalid account ID", async () => {
Expand Down
31 changes: 31 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,37 @@ export interface AccountPaymentsResponse {
}
}

/**
* Response from GET /account/:id/offers
* Returns all open offers for a Stellar account with pagination metadata.
*/
export interface AccountOffersResponse {
success: true
data: {
offers: OfferData[]
total: number
limit: number
cursor: string | null
}
}

/** Asset descriptor within an offer (selling or buying side). */
export interface OfferAsset {
assetType: string
assetCode: string | null
assetIssuer: string | null
amount?: string
}

/** An open offer on the Stellar DEX, normalised from Horizon's offer record. */
export interface OfferData {
offerId: string
selling: OfferAsset & { amount: string }
buying: OfferAsset
price: string
lastModifiedLedger: number
}

/**
* Response from GET /transactions/:id
* Returns paginated transaction history for a Stellar account.
Expand Down