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
2 changes: 1 addition & 1 deletion src/middleware/coerceQueryParams.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
const INTEGER_PARAMS = new Set(["limit", "operations"]);

/** Parameters to coerce to booleans. */
const BOOLEAN_PARAMS = new Set(["fresh", "sponsored"]);
const BOOLEAN_PARAMS = new Set(["fresh", "sponsored", "includeOperations"]);

/**
* Attempt to coerce a trimmed string to an integer.
Expand Down
34 changes: 22 additions & 12 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,15 @@ const {
makeTrustlineNotFoundError,
} = require("../utils/errors");
const cacheService = require("../services/cache");
feature/assets-overview
const { validateAccountId, validateAssetCode , validateLimit} = require("../utils/validators");

const cacheTTL = require("../config/cacheConfig");
const { validateAccountId, validateAssetCode } = require("../utils/validators");main
const { accountSummaryRateLimiter } = require("../middleware/rateLimiter");
const registerParamValidation = require("../middleware/validateRouteParams");
registerParamValidation(router);

const { buildAccountAgeResponse } = require("../utils/accountAge");
feature/assets-overview
const { validateLimit, validateISODate } = require("../utils/validators");main
const { validateISODate } = require("../utils/validators");
const { parsePaginationParams } = require("../utils/pagination");


Expand All @@ -29,6 +26,7 @@ const { normalizeAsset, normalizeAssetFromString } = require("../utils/asset");
const { isNativeAsset, isNonNativeAsset } = require("../utils/assetHelpers");
const { getAssetMetadataFromToml } = require("../utils/tomlResolver");
const { formatBalance } = require("../utils/formatBalance");
const { normalizeOperation } = require("../utils/operationFormatter");
const { formatAmount } = require("../utils/formatAmount");

// Cache TTL for account endpoint responses (in seconds)
Expand Down Expand Up @@ -473,9 +471,7 @@ router.get("/:id/asset-balance/:assetCode/:assetIssuer", async (req, res, next)
const account = await server.loadAccount(id);
const trustline = (account.balances || []).find(
(b) =>
feature/assets-overview
b.asset_type !== "nativ
isNonNativeAsset(b) && main
isNonNativeAsset(b) &&
b.asset_code === assetCode &&
b.asset_issuer === assetIssuer
);
Expand Down Expand Up @@ -3557,7 +3553,6 @@ router.get("/:id/timeline", async (req, res, next) => {
* - maturity: 'new' (<30 days), 'established' (30–364 days), or 'veteran' (≥365 days)
* - createdAt: ISO 8601 timestamp of account creation
* - createdAtLedger: Ledger sequence number of first funding transaction
main
*
* @param {string} id - Stellar account public key (G...)
*
Expand Down Expand Up @@ -3671,15 +3666,16 @@ router.get("/:id/transactions", async (req, res, next) => {
}
}

const includeOperations = req.query.includeOperations === true;
const { limit, order, cursor } = parsePaginationParams(req.query, 200);
const STROOPS_PER_XLM = 10_000_000;

// Helper to shape a Horizon transaction record into the API response format
function formatTx(tx) {
async function formatTx(tx) {
const chargedInStroops = parseInt(tx.fee_charged, 10);
const opCount = tx.operation_count || 1;
const perOpStroops = Math.floor(chargedInStroops / opCount);
return {
const formatted = {
id: tx.id,
hash: tx.hash,
ledger: typeof tx.ledger === "number" ? tx.ledger : tx.ledger_attr,
Expand All @@ -3704,6 +3700,20 @@ router.get("/:id/transactions", async (req, res, next) => {
successful: tx.successful,
envelopeXdr: tx.envelope_xdr,
};

if (includeOperations) {
try {
const opResponse = await server
.operations()
.forTransaction(tx.hash)
.call();
formatted.operations = (opResponse.records || []).map(normalizeOperation);
} catch (_) {
formatted.operations = [];
}
}

return formatted;
}

// When a ?type= filter is requested, fetch from the operations endpoint
Expand Down Expand Up @@ -3748,7 +3758,7 @@ router.get("/:id/transactions", async (req, res, next) => {
.transactions()
.transaction(op.transaction_hash)
.call();
return formatTx(tx);
return await formatTx(tx);
} catch (_) {
return null; // skip on individual lookup failure
}
Expand Down Expand Up @@ -3781,7 +3791,7 @@ router.get("/:id/transactions", async (req, res, next) => {
const records = txResponse.records || [];

return success(res, {
items: records.map(formatTx),
items: await Promise.all(records.map(formatTx)),
total: records.length,
limit,
cursor: records.length > 0 ? records[records.length - 1].paging_token : null,
Expand Down
46 changes: 46 additions & 0 deletions src/utils/operationFormatter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const { toISOTimestamp } = require("./response");
const { normalizeAsset } = require("./asset");

/**
* Normalises a raw Horizon operation record into the API's operation shape.
* Mirrors the format returned by GET /transactions/:id/operations, so the
* same shape is used anywhere operations are embedded (e.g.
* GET /account/:id/transactions?includeOperations=true).
*
* @param {object} op - Raw operation record from Horizon
* @returns {object} Normalised operation
*/
function normalizeOperation(op) {
const formatted = {
id: op.id,
type: op.type,
createdAt: toISOTimestamp(op.created_at),
transactionHash: op.transaction_hash,
transactionSuccessful: op.transaction_successful,
sourceAccount: op.source_account,
};

// Add type-specific fields
if (op.type === "payment") {
formatted.asset = normalizeAsset(
op.asset_code || "XLM",
op.asset_issuer || null,
op.asset_type || "native",
);
formatted.amount = op.amount;
formatted.from = op.from;
formatted.to = op.to;
} else if (op.type === "create_account") {
formatted.startingBalance = op.starting_balance;
formatted.funder = op.funder;
formatted.account = op.account;
} else if (op.type === "change_trust") {
formatted.asset = normalizeAsset(op.asset_code, op.asset_issuer, op.asset_type);
formatted.trustor = op.trustor;
formatted.trustee = op.trustee;
}

return formatted;
}

module.exports = { normalizeOperation };
232 changes: 232 additions & 0 deletions tests/account.transactions.includeOperations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Tests for GET /account/:id/transactions?includeOperations=true
*
* Covers:
* - includeOperations=true embeds a normalised operations array in each transaction
* - Each embedded operation follows the normalised shape (same as
* GET /transactions/:id/operations)
* - Default behaviour (omitted) is unchanged: no operations field
* - includeOperations=false behaves the same as omitted
*/

const request = require("supertest");
const { Keypair } = require("@stellar/stellar-sdk");

jest.mock("../src/config/stellar", () => ({
...jest.requireActual("../src/config/stellar"),
server: {
loadAccount: jest.fn(),
transactions: jest.fn(),
operations: jest.fn(),
},
}));

const app = require("../src/index");
const { server } = require("../src/config/stellar");

const accountId = Keypair.random().publicKey();
const txHash1 = "a".repeat(64);
const txHash2 = "b".repeat(64);

function mockTransactions(records) {
const chain = {
forAccount: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
cursor: jest.fn().mockReturnThis(),
includeFailed: jest.fn().mockReturnThis(),
call: jest.fn().mockResolvedValue({ records }),
};
server.transactions.mockReturnValue(chain);
return chain;
}

function mockOperationsForTransaction(recordsByHash) {
const chain = {
forTransaction: jest.fn((hash) => ({
call: jest.fn().mockResolvedValue({ records: recordsByHash[hash] || [] }),
})),
};
server.operations.mockReturnValue(chain);
return chain;
}

function makeTxRecord(overrides = {}) {
return {
id: overrides.id || "tx-id-1",
hash: overrides.hash || txHash1,
ledger: 100,
ledger_attr: 100,
created_at: "2024-01-01T00:00:00Z",
source_account: accountId,
fee_charged: "100",
max_fee: "200",
fee_account: accountId,
operation_count: 1,
memo_type: "none",
memo: null,
successful: true,
envelope_xdr: "AAAA",
paging_token: overrides.paging_token || "pt-1",
...overrides,
};
}

function makeOpRecord(overrides = {}) {
return {
id: "op-1",
type: overrides.type || "payment",
transaction_hash: overrides.transaction_hash || txHash1,
transaction_successful: true,
source_account: accountId,
created_at: "2024-01-01T00:00:00Z",
asset_type: "native",
amount: "10.0000000",
from: accountId,
to: accountId,
...overrides,
};
}

describe("GET /account/:id/transactions?includeOperations=", () => {
beforeEach(() => {
jest.clearAllMocks();
server.loadAccount.mockResolvedValue({ id: accountId, balances: [] });
});

it("embeds a normalised operations array when includeOperations=true", async () => {
const tx = makeTxRecord();
mockTransactions([tx]);
mockOperationsForTransaction({
[txHash1]: [makeOpRecord({ id: "op-1", type: "payment" })],
});

const res = await request(app).get(
`/account/${accountId}/transactions?includeOperations=true`,
);

expect(res.statusCode).toBe(200);
expect(res.body.data.items).toHaveLength(1);
expect(res.body.data.items[0].operations).toHaveLength(1);
expect(res.body.data.items[0].operations[0]).toEqual(
expect.objectContaining({
id: "op-1",
type: "payment",
transactionHash: txHash1,
sourceAccount: accountId,
amount: "10.0000000",
}),
);
});

it("each operation follows the normalised shape for different operation types", async () => {
const tx = makeTxRecord();
mockTransactions([tx]);
mockOperationsForTransaction({
[txHash1]: [
makeOpRecord({
id: "op-create",
type: "create_account",
starting_balance: "50.0000000",
funder: accountId,
account: accountId,
}),
makeOpRecord({
id: "op-trust",
type: "change_trust",
asset_code: "USDC",
asset_issuer: accountId,
asset_type: "credit_alphanum4",
trustor: accountId,
trustee: accountId,
}),
],
});

const res = await request(app).get(
`/account/${accountId}/transactions?includeOperations=true`,
);

expect(res.statusCode).toBe(200);
const [createOp, trustOp] = res.body.data.items[0].operations;

expect(createOp).toEqual(
expect.objectContaining({
type: "create_account",
startingBalance: "50.0000000",
funder: accountId,
account: accountId,
}),
);
expect(trustOp).toEqual(
expect.objectContaining({
type: "change_trust",
trustor: accountId,
trustee: accountId,
asset: expect.objectContaining({ code: "USDC" }),
}),
);
});

it("does not embed operations when includeOperations is omitted (default behaviour)", async () => {
const tx = makeTxRecord();
mockTransactions([tx]);

const res = await request(app).get(`/account/${accountId}/transactions`);

expect(res.statusCode).toBe(200);
expect(res.body.data.items[0]).not.toHaveProperty("operations");
expect(server.operations).not.toHaveBeenCalled();
});

it("does not embed operations when includeOperations=false", async () => {
const tx = makeTxRecord();
mockTransactions([tx]);

const res = await request(app).get(
`/account/${accountId}/transactions?includeOperations=false`,
);

expect(res.statusCode).toBe(200);
expect(res.body.data.items[0]).not.toHaveProperty("operations");
expect(server.operations).not.toHaveBeenCalled();
});

it("embeds operations for multiple transactions independently", async () => {
const tx1 = makeTxRecord({ id: "tx-1", hash: txHash1, paging_token: "pt-1" });
const tx2 = makeTxRecord({ id: "tx-2", hash: txHash2, paging_token: "pt-2" });
mockTransactions([tx1, tx2]);
mockOperationsForTransaction({
[txHash1]: [makeOpRecord({ id: "op-1", transaction_hash: txHash1 })],
[txHash2]: [
makeOpRecord({ id: "op-2a", transaction_hash: txHash2 }),
makeOpRecord({ id: "op-2b", transaction_hash: txHash2 }),
],
});

const res = await request(app).get(
`/account/${accountId}/transactions?includeOperations=true`,
);

expect(res.statusCode).toBe(200);
expect(res.body.data.items[0].operations).toHaveLength(1);
expect(res.body.data.items[1].operations).toHaveLength(2);
});

it("returns an empty operations array (not an error) if the operations lookup fails", async () => {
const tx = makeTxRecord();
mockTransactions([tx]);
server.operations.mockReturnValue({
forTransaction: jest.fn().mockReturnValue({
call: jest.fn().mockRejectedValue(new Error("network error")),
}),
});

const res = await request(app).get(
`/account/${accountId}/transactions?includeOperations=true`,
);

expect(res.statusCode).toBe(200);
expect(res.body.data.items[0].operations).toEqual([]);
});
});