Skip to content
Open
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
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
128 changes: 128 additions & 0 deletions src/routes/account.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
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");

describe("StellarKit API", () => {
Expand Down Expand Up @@ -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");
Expand Down