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
13 changes: 13 additions & 0 deletions src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ function errorHandler(err, req, res, next) {
});
}

// TransactionNotFound errors (Horizon 404 on transaction lookup)
if (err.isTransactionNotFound) {
logError(404, req, err.message);
return res.status(404).json({
success: false,
error: {
type: "NotFound",
message: err.message,
suggestion: "Verify the transaction hash is correct and exists on the network.",
},
});
}

// AccountNotFound errors (Horizon 404 on account lookup)
if (err.isAccountNotFound) {
logError(404, req, err.message);
Expand Down
3 changes: 3 additions & 0 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
makeClaimableBalanceNotFoundError,
} = require("../utils/errors");
const cacheService = require("../services/cache");
const { validateAccountId, validateAssetCode } = require("../utils/validators");
const { accountSummaryRateLimiter } = require("../middleware/rateLimiter");
const registerParamValidation = require("../middleware/validateRouteParams");
registerParamValidation(router);
Expand Down Expand Up @@ -441,6 +442,8 @@ router.get("/:id/payments", async (req, res, next) => {
let query = server.payments().forAccount(id).limit(limit).order(order);
if (cursor) query = query.cursor(cursor);

const paymentResponse = await query.call();
const rawRecords = paymentResponse.records || [];
// Use operations endpoint to get payment + create_account ops
const opQuery = server.operations().forAccount(id).limit(limit).order(order);
const opResponse = await (cursor ? opQuery.cursor(cursor) : opQuery).call();
Expand Down
14 changes: 10 additions & 4 deletions src/routes/transaction.effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

const { server, NETWORK } = require("../config/stellar");
const { success, toISOTimestamp } = require("../utils/response");
const { makeAccountNotFoundError } = require("../utils/errors");

Check warning on line 8 in src/routes/transaction.effects.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'makeAccountNotFoundError' is assigned a value but never used
const { validateOrder } = require("../utils/validators");

// Local validator (no Horizon call): 64-character hex string.
function validateTransactionHash(hash) {
Expand Down Expand Up @@ -73,10 +74,10 @@

if (effect.signer_key !== undefined) base.signerKey = effect.signer_key;

// Memo-less details bag (when Horizon provides it)
// Memo-less "details" bag (when Horizon provides it)
if (effect.details !== undefined) base.details = effect.details;

// Keep cursor/debug fields but dont break acceptance criteria
// Keep cursor/debug fields but don't break acceptance criteria
if (effect.paging_token !== undefined) {
base.pagingToken = effect.paging_token;
}
Expand All @@ -91,12 +92,18 @@
/**
* GET /transaction/:hash/effects
* Fetch all ledger effects for a transaction hash.
*
* Query parameters:
* order - Sort direction: "asc" or "desc" (default: "desc")
*/
router.get("/:hash/effects", async (req, res, next) => {
try {
const { hash } = req.params;
validateTransactionHash(hash);

// Validate ?order query param; defaults to "desc" when omitted.
const order = validateOrder(req.query.order);

// Ensure transaction exists for proper 404s (and to avoid effects returning empty for non-existent hashes)
try {
await server.transactions().transaction(hash).call();
Expand All @@ -120,7 +127,7 @@
// Acceptance criteria only requires effects + total; we set a conservative limit.
const limit = 200;

const response = await server.effects().forTransaction(hash).limit(limit).order("desc").call();
const response = await server.effects().forTransaction(hash).limit(limit).order(order).call();
const records = response.records || [];

const effects = records.map(normalizeEffect);
Expand All @@ -140,4 +147,3 @@
});

module.exports = router;

9 changes: 9 additions & 0 deletions src/utils/validators.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
const { StrKey } = require("@stellar/stellar-sdk");

/**
* Format a query-parameter validation message.
* e.g. qp("order", 'must be "asc" or "desc".') → "Query param 'order' must be \"asc\" or \"desc\"."
*
* @param {string} param - Parameter name
* @param {string} msg - Remainder of the message
* @returns {string}
*/
function qp(param, msg) {
return `Query param '${param}' ${msg}`;
* Build a query-parameter error message in the form "Query parameter '<field>': <detail>".
* @param {string} field
* @param {string} detail
Expand Down
71 changes: 70 additions & 1 deletion tests/transaction.effects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,74 @@ describe("GET /transaction/:hash/effects", () => {
expect(server.effects).not.toHaveBeenCalled();
expect(server.transactions).not.toHaveBeenCalled();
});
});

// ── ?order query parameter ────────────────────────────────────────────────

it("defaults to desc order when ?order is omitted", async () => {
mockTransactionExists();
const orderMock = jest.fn().mockReturnThis();
server.effects.mockReturnValue({
forTransaction: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: orderMock,
call: jest.fn().mockResolvedValue({ records: [] }),
});

const res = await request(app)
.get(`/transaction/${validHash}/effects`)
.set("x-api-key", "test");

expect(res.statusCode).toBe(200);
expect(orderMock).toHaveBeenCalledWith("desc");
});

it("forwards ?order=desc to the Horizon call", async () => {
mockTransactionExists();
const orderMock = jest.fn().mockReturnThis();
server.effects.mockReturnValue({
forTransaction: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: orderMock,
call: jest.fn().mockResolvedValue({ records: [] }),
});

const res = await request(app)
.get(`/transaction/${validHash}/effects?order=desc`)
.set("x-api-key", "test");

expect(res.statusCode).toBe(200);
expect(orderMock).toHaveBeenCalledWith("desc");
});

it("forwards ?order=asc to the Horizon call", async () => {
mockTransactionExists();
const orderMock = jest.fn().mockReturnThis();
server.effects.mockReturnValue({
forTransaction: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
order: orderMock,
call: jest.fn().mockResolvedValue({ records: [] }),
});

const res = await request(app)
.get(`/transaction/${validHash}/effects?order=asc`)
.set("x-api-key", "test");

expect(res.statusCode).toBe(200);
expect(orderMock).toHaveBeenCalledWith("asc");
});

it("returns 400 for an invalid ?order value", async () => {
mockTransactionExists();

const res = await request(app)
.get(`/transaction/${validHash}/effects?order=random`)
.set("x-api-key", "test");

expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.type).toBe("ValidationError");
// Horizon should never be reached when order is invalid
expect(server.effects).not.toHaveBeenCalled();
});
});
Loading