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
129 changes: 129 additions & 0 deletions src/routes/account.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
116 changes: 116 additions & 0 deletions tests/api.test.js
Original file line number Diff line number Diff line change
@@ -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");

Expand Down Expand Up @@ -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");
Expand Down
Loading