diff --git a/sdk/account.ts b/sdk/account.ts index 0b8021e..25141b8 100644 --- a/sdk/account.ts +++ b/sdk/account.ts @@ -5,6 +5,7 @@ import type { AccountSignersResponse, AccountAgeResponse, AccountRiskScoreResponse, + AccountOffersResponse, TrustlineEntry, PaymentOperation, } from "../types/index.d"; @@ -141,6 +142,34 @@ export class AccountModule { return this._get>(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 { + 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(path); + } + /** * Get the signers and threshold configuration for an account. * diff --git a/sdk/stellarkit-client.js b/sdk/stellarkit-client.js index 441c905..590c745 100644 --- a/sdk/stellarkit-client.js +++ b/sdk/stellarkit-client.js @@ -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} 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. * diff --git a/src/routes/account.js b/src/routes/account.js index d6aaf0e..8242a96 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -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)); @@ -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(); @@ -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, @@ -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, diff --git a/tests/account.offers.test.js b/tests/account.offers.test.js new file mode 100644 index 0000000..e9f3d58 --- /dev/null +++ b/tests/account.offers.test.js @@ -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); + }); +}); diff --git a/tests/api.test.js b/tests/api.test.js index 6e52f9d..512aaa1 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -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"); @@ -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 () => { diff --git a/types/index.d.ts b/types/index.d.ts index a2593ee..6a6697a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -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.