diff --git a/README.md b/README.md index f86ad38..e303cae 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ This project is ideal for: | GET | `/account/:id/native-balance` | Native XLM balance only | — | | GET | `/account/:id/asset-balance/:assetCode/:assetIssuer` | Balance for a specific asset trustline | — | | GET | `/account/:id/sequence` | Current sequence number | — | -| GET | `/account/:id/trustlines` | Trustlines with TOML asset metadata resolved | `assetCode` | +| GET | `/account/:id/trustlines` | Trustlines with TOML asset metadata resolved | `assetCode`, `sponsored` | | GET | `/account/:id/payments` | Payment and create_account operations | `limit`, `order`, `cursor`, `assetCode`, `assetIssuer` | | GET | `/account/:id/trades` | DEX trades for the account | `limit`, `order`, `cursor`, `fresh` | | GET | `/account/:id/offers` | Open DEX offers for an account | `limit`, `cursor` | @@ -562,7 +562,12 @@ curl -X GET "http://localhost:3000/account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJT ### `GET /account/:id/trustlines` -Returns all trustlines for the account with TOML metadata resolved from the issuer's home domain. Filter by asset code with `?assetCode=`. +Returns all trustlines for the account with TOML metadata resolved from the issuer's home domain. Filter by asset code with `?assetCode=`, or by sponsorship status with `?sponsored=`. + +| Param | Type | Required | Default | Description | +| ----- | ---- | -------- | ------- | ----------- | +| `assetCode` | string | No | None | Case-insensitive asset code filter. | +| `sponsored` | boolean | No | None | `true` returns only trustlines sponsored by another account; `false` returns only unsponsored trustlines. Omitted returns all. | ```bash # All trustlines @@ -570,6 +575,9 @@ curl -X GET "http://localhost:3000/account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJT # Filter to USDC only curl -X GET "http://localhost:3000/account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN/trustlines?assetCode=USDC" + +# Only sponsored trustlines +curl -X GET "http://localhost:3000/account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN/trustlines?sponsored=true" ``` ### `GET /account/:id/summary` diff --git a/src/middleware/coerceQueryParams.js b/src/middleware/coerceQueryParams.js index 0186b69..4e9445c 100644 --- a/src/middleware/coerceQueryParams.js +++ b/src/middleware/coerceQueryParams.js @@ -20,7 +20,7 @@ const INTEGER_PARAMS = new Set(["limit", "operations"]); /** Parameters to coerce to booleans. */ -const BOOLEAN_PARAMS = new Set(["fresh"]); +const BOOLEAN_PARAMS = new Set(["fresh", "sponsored"]); /** * Attempt to coerce a trimmed string to an integer. diff --git a/src/routes/account.js b/src/routes/account.js index 64873ec..385b742 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -267,6 +267,12 @@ async function resolveTrustlineToml(balance, issuerCache, tomlCache, includeMeta /** * GET /account/:id/trustlines + * + * Query params: + * - assetCode (string, optional): filters trustlines to a single asset code + * - sponsored (boolean, optional): when "true", returns only trustlines + * where sponsoredBy is not null; when "false", returns only trustlines + * where sponsoredBy is null. Omitted returns all trustlines. */ router.get("/:id/trustlines", async (req, res, next) => { try { @@ -276,11 +282,13 @@ router.get("/:id/trustlines", async (req, res, next) => { const fresh = req.query.fresh === "true"; const includeMetadata = req.query.includeMetadata === "true"; const { assetCode } = req.query; + const sponsored = req.query.sponsored; + const hasSponsoredFilter = typeof sponsored === "boolean"; const cacheKey = `trustlines:${id}`; // Only read from cache for unfiltered requests; filtered results are subsets // of the full list and must not be served from the full-list cache entry. - if (!fresh && !assetCode) { + if (!fresh && !assetCode && !hasSponsoredFilter) { const cached = cacheService.get(cacheKey); if (cached) { res.set("X-Cache", "HIT"); @@ -310,6 +318,12 @@ router.get("/:id/trustlines", async (req, res, next) => { ); } + if (hasSponsoredFilter) { + trustlines = trustlines.filter((t) => + sponsored ? t.sponsoredBy !== null : t.sponsoredBy === null, + ); + } + return success(res, { accountId: account.id, trustlines, diff --git a/tests/account.sponsoredTrustlines.test.js b/tests/account.sponsoredTrustlines.test.js new file mode 100644 index 0000000..6ca7dc1 --- /dev/null +++ b/tests/account.sponsoredTrustlines.test.js @@ -0,0 +1,128 @@ +const request = require("supertest"); +const axios = require("axios"); + +jest.mock("axios", () => ({ + create: jest.fn(() => ({ + interceptors: { response: { use: jest.fn() } }, + })), + get: jest.fn(), +})); +jest.mock("../src/config/stellar", () => ({ + server: { + loadAccount: jest.fn(), + }, + horizonUrl: "https://horizon-testnet.stellar.org", + NETWORK: "testnet", + NETWORKS: { + testnet: "https://horizon-testnet.stellar.org", + mainnet: "https://horizon.stellar.org", + }, +})); + +const app = require("../src/index"); +const { server } = require("../src/config/stellar"); +const cacheService = require("../src/services/cache"); + +const ACCOUNT_ID = "GBB67CMSCMGPROSFIVENXMRQ3KJWELDIUYITQI7YCKMSOPR2SNZB5NQ5"; +const ISSUER_A = "GD62SRSGF4XVUHZYLZNAMTUTOH7CKJ2WZWX6HNUTZ4G5SFKNAM6G2OXD"; +const ISSUER_B = "GBDUK225U2UZ2YBZMIIGPI2XK35PKWUW25YYS2NNQ3HWYAMWSGWME4IA"; +const SPONSOR_ID = "GA4TZFI2SHGWNYKMPGB6KCUOA3AEEHW3S4L3ZQCTZ4SFR6HTNSC4NCPQ"; + +function makeAccount() { + return { + id: ACCOUNT_ID, + balances: [ + { asset_type: "native", balance: "10.0000000" }, + { + // Sponsored trustline + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: ISSUER_A, + balance: "100.0000000", + limit: "1000.0000000", + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + sponsor: SPONSOR_ID, + }, + { + // Unsponsored trustline + asset_type: "credit_alphanum4", + asset_code: "BTC", + asset_issuer: ISSUER_B, + balance: "0.5000000", + limit: "10.0000000", + is_authorized: true, + is_authorized_to_maintain_liabilities: true, + }, + ], + }; +} + +describe("GET /account/:id/trustlines?sponsored=", () => { + beforeEach(() => { + jest.clearAllMocks(); + cacheService.flush(); + server.loadAccount.mockImplementation(async (id) => { + if (id === ACCOUNT_ID) return makeAccount(); + return { id, home_domain: null }; + }); + }); + + it("returns only sponsored trustlines when sponsored=true", async () => { + const res = await request(app).get( + `/account/${ACCOUNT_ID}/trustlines?sponsored=true`, + ); + + expect(res.statusCode).toBe(200); + expect(res.body.data.items).toHaveLength(1); + expect(res.body.data.items[0].asset.code).toBe("USDC"); + expect(res.body.data.items[0].sponsoredBy).toBe(SPONSOR_ID); + }); + + it("returns only unsponsored trustlines when sponsored=false", async () => { + const res = await request(app).get( + `/account/${ACCOUNT_ID}/trustlines?sponsored=false`, + ); + + expect(res.statusCode).toBe(200); + expect(res.body.data.items).toHaveLength(1); + expect(res.body.data.items[0].asset.code).toBe("BTC"); + expect(res.body.data.items[0].sponsoredBy).toBeNull(); + }); + + it("returns all trustlines when sponsored is omitted", async () => { + const res = await request(app).get(`/account/${ACCOUNT_ID}/trustlines`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.items).toHaveLength(2); + expect(res.body.data.total).toBe(2); + }); + + it("every trustline includes a sponsoredBy field (string or null)", async () => { + const res = await request(app).get(`/account/${ACCOUNT_ID}/trustlines`); + + expect(res.statusCode).toBe(200); + for (const trustline of res.body.data.items) { + expect(trustline).toHaveProperty("sponsoredBy"); + } + }); + + it("combines assetCode and sponsored filters", async () => { + const res = await request(app).get( + `/account/${ACCOUNT_ID}/trustlines?assetCode=USDC&sponsored=true`, + ); + + expect(res.statusCode).toBe(200); + expect(res.body.data.items).toHaveLength(1); + expect(res.body.data.items[0].asset.code).toBe("USDC"); + }); + + it("ignores an invalid sponsored value and returns all trustlines", async () => { + const res = await request(app).get( + `/account/${ACCOUNT_ID}/trustlines?sponsored=maybe`, + ); + + expect(res.statusCode).toBe(200); + expect(res.body.data.items).toHaveLength(2); + }); +});