From adf2a65ae069ecb1fcf62f2dc810d1a958fa033c Mon Sep 17 00:00:00 2001 From: Nkeiru Lois Date: Thu, 30 Jul 2026 13:04:48 +0100 Subject: [PATCH 1/2] fix(account): remove duplicate includeOperations declaration and resolve leftover merge artifacts from #649 --- src/routes/account.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/routes/account.js b/src/routes/account.js index 6b2416a..d4bc306 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -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"); @@ -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) @@ -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 ); @@ -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...) * @@ -3671,6 +3666,7 @@ 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; From 39836cd71d5b976ed96b5a097917c11889c46fcd Mon Sep 17 00:00:00 2001 From: Nkeiru Lois Date: Fri, 31 Jul 2026 06:47:31 +0100 Subject: [PATCH 2/2] feat(account): add ?includeOperations=true to GET /account/:id/transactions Adds an optional ?includeOperations query parameter to GET /account/:id/transactions. When true, each transaction gets a normalised operations array embedded under 'operations', using the same shape as GET /transactions/:id/operations. Default behaviour (omitted or false) is unchanged - no operations field, no extra Horizon calls. - src/utils/operationFormatter.js: new normalizeOperation() helper, extracted from the shape used by GET /transactions/:id/operations, so both endpoints share one normalised operation shape - src/routes/account.js: formatTx is now async; when includeOperations is set, fetches server.operations().forTransaction(hash) per transaction and embeds the normalised records; falls back to an empty array (not an error) if a lookup fails - src/middleware/coerceQueryParams.js: coerce ?includeOperations to boolean - tests/account.transactions.includeOperations.test.js: covers includeOperations=true embedding operations, per-type shape (payment/create_account/change_trust), default/false behaviour unchanged, multiple transactions handled independently, and graceful fallback on lookup failure Closes #563 --- src/middleware/coerceQueryParams.js | 2 +- src/routes/account.js | 22 +- src/utils/operationFormatter.js | 46 ++++ ...unt.transactions.includeOperations.test.js | 232 ++++++++++++++++++ 4 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 src/utils/operationFormatter.js create mode 100644 tests/account.transactions.includeOperations.test.js diff --git a/src/middleware/coerceQueryParams.js b/src/middleware/coerceQueryParams.js index 4e9445c..16858ab 100644 --- a/src/middleware/coerceQueryParams.js +++ b/src/middleware/coerceQueryParams.js @@ -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. diff --git a/src/routes/account.js b/src/routes/account.js index d4bc306..0b327d2 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -3671,11 +3671,11 @@ router.get("/:id/transactions", async (req, res, next) => { 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, @@ -3700,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 @@ -3744,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 } @@ -3777,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, diff --git a/src/utils/operationFormatter.js b/src/utils/operationFormatter.js new file mode 100644 index 0000000..b0aaa73 --- /dev/null +++ b/src/utils/operationFormatter.js @@ -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 }; diff --git a/tests/account.transactions.includeOperations.test.js b/tests/account.transactions.includeOperations.test.js new file mode 100644 index 0000000..55e8dd9 --- /dev/null +++ b/tests/account.transactions.includeOperations.test.js @@ -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([]); + }); +});