diff --git a/src/routes/account.js b/src/routes/account.js index 5525479..dbb3f62 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -1,5 +1,6 @@ const express = require("express"); const router = express.Router(); +const axios = require("axios"); const { server, fetchAccountCreation } = require("../config/stellar"); const { success } = require("../utils/response"); const { getAssetMetadataFromToml } = require("../utils/tomlResolver"); @@ -188,6 +189,88 @@ router.get("/:id/analytics", async (req, res, next) => { } }); +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, @@ -608,6 +691,52 @@ router.get("/:id/inactivity", 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) => { /** * GET /account/:id/sponsorship * Resolves the full sponsorship structure of a Stellar account. diff --git a/tests/api.test.js b/tests/api.test.js index 60c7ce3..1b93e33 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"); const { server } = require("../src/config/stellar"); @@ -54,6 +56,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 with field-level details", async () => { const res = await request(app).get("/transactions/BADKEY123");