From a9308c86f68a999024e5ec08d94c35f63d9130a6 Mon Sep 17 00:00:00 2001 From: Faith-okereke Date: Wed, 27 May 2026 14:45:16 +0100 Subject: [PATCH] feat: add trustline endpoint with resolved asset TOML metadata --- src/index.js | 1 + src/routes/account.js | 128 ++++++++++++++++++++++++++++++++++++++++++ tests/api.test.js | 116 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) diff --git a/src/index.js b/src/index.js index f3adf81..cf71d2b 100644 --- a/src/index.js +++ b/src/index.js @@ -64,6 +64,7 @@ app.get("/", (req, res) => { { method: "GET", path: "/fee-estimate", description: "Fee tiers for transaction submission" }, { method: "GET", path: "/fee-estimate?operations=N", description: "Fee estimate for N operations" }, { method: "GET", path: "/account/:id", description: "Account details, balances, signers" }, + { method: "GET", path: "/account/:id/trustlines", description: "Non-native balances with resolved asset TOML metadata" }, { method: "GET", path: "/transactions/:id", description: "Transaction history for an account" }, { method: "GET", path: "/transactions/:id/operations", description: "Operation history for an account" }, { method: "GET", path: "/asset/:code/:issuer", description: "Asset metadata and statistics" }, diff --git a/src/routes/account.js b/src/routes/account.js index 009a0db..602280b 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -1,9 +1,92 @@ const express = require("express"); const router = express.Router(); +const axios = require("axios"); const { server } = require("../config/stellar"); const { success } = require("../utils/response"); const { validateAccountId, validateLimit } = require("../utils/validators"); +function parseStellarToml(tomlText) { + const currencies = []; + let current = null; + + tomlText.split(/\r?\n/).forEach((rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith("//")) { + return; + } + + const tableMatch = line.match(/^\[\[\s*([A-Za-z0-9_]+)\s*\]\]$/); + if (tableMatch) { + if (current && Object.keys(current).length > 0) { + currencies.push(current); + } + current = {}; + return; + } + + const kvMatch = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/); + if (!kvMatch || !current) { + return; + } + + let [, key, value] = kvMatch; + value = value.trim(); + + const quoted = value.match(/^"([\s\S]*)"$/); + if (quoted) { + value = quoted[1].replace(/\\"/g, '"'); + } else if (/^(true|false)$/i.test(value)) { + value = value.toLowerCase() === "true"; + } else if (!Number.isNaN(Number(value))) { + value = Number(value); + } + + current[key] = value; + }); + + if (current && Object.keys(current).length > 0) { + currencies.push(current); + } + + return currencies; +} + +async function resolveIssuerToml(assetIssuer, assetCode) { + try { + const issuerAccount = await server.loadAccount(assetIssuer); + if (!issuerAccount.home_domain) { + return null; + } + + const tomlUrl = `https://${issuerAccount.home_domain}/.well-known/stellar.toml`; + const response = await axios.get(tomlUrl, { + timeout: 5000, + headers: { + Accept: "text/plain, */*", + }, + }); + + const currencies = parseStellarToml(response.data); + const match = currencies.find( + (currency) => + currency.code === assetCode && + (currency.issuer === assetIssuer || currency.issuer_account_id === assetIssuer), + ); + + if (!match) { + return null; + } + + return { + name: match.name || null, + description: match.desc || match.description || null, + image: match.image || null, + }; + } catch (err) { + return null; + } +} + /** * GET /account/:id * Returns full account details including XLM balance, all asset balances, @@ -73,6 +156,51 @@ router.get("/:id", async (req, res, next) => { } }); +router.get("/:id/trustlines", async (req, res, next) => { + try { + const { id } = req.params; + validateAccountId(id); + + const account = await server.loadAccount(id); + const tokenBalances = account.balances.filter((balance) => balance.asset_type !== "native"); + + const tomlCache = {}; + const assetPromises = tokenBalances.map(async (balance) => { + const cachedToml = tomlCache[balance.asset_issuer]; + const tomlResult = cachedToml + ? cachedToml + : await resolveIssuerToml(balance.asset_issuer, balance.asset_code); + + if (!cachedToml) { + tomlCache[balance.asset_issuer] = tomlResult; + } + + return { + assetCode: balance.asset_code, + assetIssuer: balance.asset_issuer, + assetType: balance.asset_type, + balance: balance.balance, + limit: balance.limit, + buyingLiabilities: balance.buying_liabilities, + sellingLiabilities: balance.selling_liabilities, + isAuthorized: balance.is_authorized, + isClawbackEnabled: balance.is_clawback_enabled, + toml: tomlResult, + }; + }); + + const assets = await Promise.all(assetPromises); + + return success(res, { + accountId: account.id, + assetCount: assets.length, + assets, + }); + } catch (err) { + next(err); + } +}); + router.get("/:id/summary", async (req, res, next) => { try { const { id } = req.params; diff --git a/tests/api.test.js b/tests/api.test.js index c4b4d43..57fa5df 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,5 +1,7 @@ const request = require("supertest"); +const axios = require("axios"); const app = require("../src/index"); +const { server } = require("../src/config/stellar"); const { networkStatusCache, feeEstimateCache } = require("../src/utils/cache"); describe("StellarKit API", () => { @@ -50,6 +52,120 @@ describe("StellarKit API", () => { }); }); + describe("GET /account/:id/trustlines", () => { + const MOCK_ACCOUNT = "GBB67CMSCMGPROSFIVENXMRQ3KJWELDIUYITQI7YCKMSOPR2SNZB5NQ5"; + const MOCK_ISSUER = "GC3C6BRSPTJTJ4DI7ELZ2J4Y3Z5OCN7R2VIX5FQY3Y5QIN3QAKXUQY5R"; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns non-native trustlines with resolved TOML metadata", async () => { + const accountResponse = { + id: MOCK_ACCOUNT, + balances: [ + { + asset_type: "credit_alphanum4", + asset_code: "TEST", + asset_issuer: MOCK_ISSUER, + balance: "100.0000000", + limit: "1000.0000000", + buying_liabilities: "0.0000000", + selling_liabilities: "0.0000000", + is_authorized: true, + is_clawback_enabled: false, + }, + ], + sequence: "1", + subentry_count: 1, + signers: [], + thresholds: {}, + flags: {}, + last_modified_ledger: 1, + }; + + const issuerResponse = { + id: MOCK_ISSUER, + home_domain: "example.com", + }; + + jest.spyOn(server, "loadAccount").mockImplementation(async (id) => { + if (id === MOCK_ACCOUNT) return accountResponse; + if (id === MOCK_ISSUER) return issuerResponse; + throw new Error(`Unexpected account load for ${id}`); + }); + + jest.spyOn(axios, "get").mockResolvedValue({ + data: `[[CURRENCIES]] +code = "TEST" +issuer = "${MOCK_ISSUER}" +name = "Test Asset" +desc = "A test asset" +image = "https://example.com/test.png" +`, + }); + + const res = await request(app).get(`/account/${MOCK_ACCOUNT}/trustlines`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty("accountId", MOCK_ACCOUNT); + expect(res.body.data).toHaveProperty("assetCount", 1); + expect(res.body.data.assets).toHaveLength(1); + expect(res.body.data.assets[0]).toMatchObject({ + assetCode: "TEST", + assetIssuer: MOCK_ISSUER, + toml: { + name: "Test Asset", + description: "A test asset", + image: "https://example.com/test.png", + }, + }); + }); + + it("returns null TOML metadata when issuer resolution is not available", async () => { + const accountResponse = { + id: MOCK_ACCOUNT, + balances: [ + { + asset_type: "credit_alphanum4", + asset_code: "NONE", + asset_issuer: MOCK_ISSUER, + balance: "42.0000000", + limit: "1000.0000000", + buying_liabilities: "0.0000000", + selling_liabilities: "0.0000000", + is_authorized: false, + is_clawback_enabled: false, + }, + ], + sequence: "1", + subentry_count: 1, + signers: [], + thresholds: {}, + flags: {}, + last_modified_ledger: 1, + }; + + const issuerResponse = { + id: MOCK_ISSUER, + home_domain: null, + }; + + jest.spyOn(server, "loadAccount").mockImplementation(async (id) => { + if (id === MOCK_ACCOUNT) return accountResponse; + if (id === MOCK_ISSUER) return issuerResponse; + throw new Error(`Unexpected account load for ${id}`); + }); + + const res = await request(app).get(`/account/${MOCK_ACCOUNT}/trustlines`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.assets[0].toml).toBeNull(); + }); + }); + describe("GET /transactions/:id — validation", () => { it("returns 400 for an invalid account ID", async () => { const res = await request(app).get("/transactions/BADKEY123");