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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -562,14 +562,22 @@ 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
curl -X GET "http://localhost:3000/account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN/trustlines"

# 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`
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/coerceQueryParams.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions tests/account.sponsoredTrustlines.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading