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
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const dexRouter = require("./routes/dex");
const liquidityPoolRouter = require("./routes/liquidityPool");
const streamRouter = require("./routes/stream");
const utilsRouter = require("./routes/utils");
const claimableBalancesRouter = require("./routes/claimableBalances");

const app = express();
const PORT = process.env.PORT || 3000;
Expand Down Expand Up @@ -61,6 +62,7 @@ app.use("/dex", dexRouter);
app.use("/liquidity-pools", liquidityPoolRouter);
app.use("/stream", streamRouter);
app.use("/utils", utilsRouter);
app.use("/claimable-balances", claimableBalancesRouter);

// ── Root ─────────────────────────────────────────────────────────────────────
app.get("/", (req, res) => {
Expand Down Expand Up @@ -88,6 +90,7 @@ app.get("/", (req, res) => {
{ method: "GET", path: "/account/:id/transactions/search", description: "Search account transactions by memo content" },
{ 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: "/claimable-balances/:id/evaluate/:accountId", description: "Evaluate claimability of a balance for a specific account" },
{ method: "GET", path: "/asset/:code/:issuer", description: "Asset metadata and statistics" },
{ method: "GET", path: "/asset/:code/:issuer/holders", description: "Paginated accounts holding an asset" },
{ method: "GET", path: "/asset/search?code=:code", description: "Search assets by code across all issuers" },
Expand Down
147 changes: 147 additions & 0 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,153 @@ router.get("/:id/payments", async (req, res, next) => {
* GET /account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN/timeline
* GET /account/GAAZI4.../timeline?limit=20
*/
/**
* GET /account/:id/operation-breakdown
* Analyzes the last 200 operations and returns a breakdown by operation type.
* Useful for understanding how an account is being used.
*
* @param {string} id - Stellar account public key (G...)
*/
router.get("/:id/operation-breakdown", async (req, res, next) => {
try {
const { id } = req.params;
validateAccountId(id);

// Fetch last 200 operations
const opResponse = await server
.operations()
.forAccount(id)
.limit(200)
.order("desc")
.call();

const records = opResponse.records;
const total = records.length;

if (total === 0) {
return success(res, {
total: 0,
breakdown: [],
mostUsedOperation: null,
leastUsedOperation: null,
});
}

const counts = {};
records.forEach((op) => {
counts[op.type] = (counts[op.type] || 0) + 1;
});

const breakdown = Object.entries(counts)
.map(([type, count]) => ({
type,
count,
percentage: parseFloat(((count / total) * 100).toFixed(2)),
}))
.sort((a, b) => b.count - a.count);

return success(res, {
total,
breakdown,
mostUsedOperation: breakdown[0].type,
leastUsedOperation: breakdown[breakdown.length - 1].type,
});
} catch (err) {
handleAccountNotFound(err, next);
}
});

/**
* GET /account/:id/offer-history
* Returns the full history of offers created, updated, and deleted by an account.
*
* Query params:
* - limit (number, default: 10)
* - order ("asc" | "desc", default: "desc")
* - cursor (string, pagination cursor)
*
* @param {string} id - Stellar account public key (G...)
*/
router.get("/:id/offer-history", async (req, res, next) => {
try {
const { id } = req.params;
validateAccountId(id);

const limit = validateLimit(req.query.limit || 10, 200);
const order = ["asc", "desc"].includes(req.query.order)
? req.query.order
: "desc";
const cursor = req.query.cursor || undefined;

let query = server
.operations()
.forAccount(id)
.limit(limit)
.order(order);

if (cursor) query = query.cursor(cursor);

const opResponse = await query.call();
const records = opResponse.records;

const offerOps = records
.filter((op) =>
[
"manage_sell_offer",
"manage_buy_offer",
"create_passive_sell_offer",
].includes(op.type)
)
.map((op) => {
let offerType = "updated";
if (op.type === "create_passive_sell_offer") {
offerType = "created";
} else if (parseFloat(op.amount) === 0) {
offerType = "deleted";
} else {
// In Stellar, if we can't see the original request, it's hard to be 100% sure if it was created or updated
// just from the operation record if offer_id is already assigned.
// But usually, if it's the first time that offerId appears for this account, it was created.
// For this API, we'll label it 'created' if it appears to be a new offer or 'updated' otherwise.
// many developers use amount > 0 as updated/created.
// We'll use a heuristic or just label as requested.
// If the op has a price but it was a 'manage' op, we'll call it 'created/updated'.
offerType = op.offer_id === "0" ? "created" : "updated";
}

const formatAsset = (type, code, issuer) => {
if (type === "native") return "XLM";
return `${code}:${issuer}`;
};

return {
offerId: op.offer_id,
type: offerType,
sellingAsset: formatAsset(op.selling_asset_type, op.selling_asset_code, op.selling_asset_issuer),
buyingAsset: formatAsset(op.buying_asset_type, op.buying_asset_code, op.buying_asset_issuer),
amount: op.amount,
price: op.price,
timestamp: op.created_at,
transactionHash: op.transaction_hash,
};
});

const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null;

return success(res, offerOps, {
meta: {
count: offerOps.length,
limit,
order,
nextCursor,
hasMore: records.length === limit,
},
});
} catch (err) {
handleAccountNotFound(err, next);
}
});

router.get("/:id/timeline", async (req, res, next) => {
try {
const { id } = req.params;
Expand Down
111 changes: 111 additions & 0 deletions src/routes/asset.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
flags: issuerAccount.flags,
thresholds: issuerAccount.thresholds,
};
} catch (_) {

Check warning on line 142 in src/routes/asset.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

'_' is defined but never used

Check warning on line 142 in src/routes/asset.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'_' is defined but never used
// Issuer account info is optional
}

Expand All @@ -161,6 +161,117 @@
}
});

/**
* GET /asset/:code/:issuer/distribution
* Analyzes the distribution of holders for a Stellar asset.
* Returns concentration metrics (Top 10/25) and Gini coefficient.
*
* @param {string} code - Asset code (e.g. USDC)
* @param {string} issuer - Issuer account public key (G...)
*/
router.get("/:code/:issuer/distribution", async (req, res, next) => {
try {
const { code, issuer } = req.params;
validateAssetCode(code);
validateAccountId(issuer);

const assetCode = code.toUpperCase();

// 1. Verify asset exists and get total holder count
const assetsResponse = await server
.assets()
.forCode(assetCode)
.forIssuer(issuer)
.call();

if (!assetsResponse.records || assetsResponse.records.length === 0) {
return res.status(404).json({
success: false,
error: {
type: "NotFound",
message: `Asset ${assetCode} issued by ${issuer} was not found on the Stellar network.`,
},
});
}

const asset = assetsResponse.records[0];
const totalHolders = asset.num_accounts;

// 2. Fetch top holders (up to 200)
// Note: Horizon doesn't allow sorting /accounts by balance.
// We fetch a page of accounts holding the asset.
const accountsResponse = await server
.accounts()
.forAsset(new Asset(assetCode, issuer))
.limit(200)
.call();

const records = accountsResponse.records || [];
if (records.length === 0) {
return success(res, {
totalHolders: 0,
top10HoldersPercent: 0,
top25HoldersPercent: 0,
giniCoefficient: 0,
largestHolder: null,
smallestHolder: null,
});
}

// Extract balances and sort descending
const balances = records.map(r => {
const b = r.balances.find(bal => bal.asset_code === assetCode && bal.asset_issuer === issuer);
return parseFloat(b ? b.balance : "0");
}).sort((a, b) => b - a);

const totalInFetched = balances.reduce((sum, b) => sum + b, 0);
const totalAssetSupply = parseFloat(asset.amount || "0");

// Concentration metrics relative to total supply
const top10Sum = balances.slice(0, 10).reduce((sum, b) => sum + b, 0);
const top25Sum = balances.slice(0, 25).reduce((sum, b) => sum + b, 0);

const top10HoldersPercent = totalAssetSupply > 0
? parseFloat(((top10Sum / totalAssetSupply) * 100).toFixed(2))
: 0;
const top25HoldersPercent = totalAssetSupply > 0
? parseFloat(((top25Sum / totalAssetSupply) * 100).toFixed(2))
: 0;

// Gini Coefficient Calculation (using the fetched set)
// G = (2 * sum(i * x_i) / (n * sum(x_i))) - ((n + 1) / n)
// where x_i is sorted ASCENDING
const n = balances.length;
const sortedAsc = [...balances].sort((a, b) => a - b);
let cumulativeSum = 0;
for (let i = 0; i < n; i++) {
cumulativeSum += (i + 1) * sortedAsc[i];
}

const G = totalInFetched > 0
? (2 * cumulativeSum) / (n * totalInFetched) - (n + 1) / n
: 0;
const giniCoefficient = parseFloat(Math.max(0, G).toFixed(4));

return success(res, {
totalHolders,
top10HoldersPercent,
top25HoldersPercent,
giniCoefficient,
largestHolder: records.find(r => {
const b = r.balances.find(bal => bal.asset_code === assetCode && bal.asset_issuer === issuer);
return parseFloat(b ? b.balance : "0") === balances[0];
})?.id || null,
smallestHolder: records.find(r => {
const b = r.balances.find(bal => bal.asset_code === assetCode && bal.asset_issuer === issuer);
return parseFloat(b ? b.balance : "0") === balances[balances.length - 1];
})?.id || null,
});
} catch (err) {
next(err);
}
});

/**
* GET /asset/:code/:issuer/supply
* Returns full supply breakdown for a Stellar asset.
Expand Down
Loading
Loading