diff --git a/src/routes/utils.js b/src/routes/utils.js index 9fecdff..e2c8e4a 100644 --- a/src/routes/utils.js +++ b/src/routes/utils.js @@ -4,9 +4,11 @@ const router = express.Router(); const { success } = require("../utils/response"); const { validateAccountId } = require("../utils/validators"); const { Transaction, Networks, Keypair } = require("@stellar/stellar-sdk"); +const { server } = require("../config/stellar"); const FRIENDBOT_URL = "https://friendbot.stellar.org"; const STROOPS_PER_XLM = 10000000n; +const AVERAGE_LEDGER_CLOSE_SECONDS = 5; const { decodeMemo } = require("../utils/memo"); function createValidationError(message) { @@ -240,6 +242,54 @@ router.get("/convert", (req, res, next) => { } }); +function parseLedgerSequence(value) { + if (typeof value !== "string" || value.trim() === "") { + throw createValidationError("Query parameter 'sequence' is required and must be a positive integer."); + } + + if (!/^\d+$/.test(value)) { + throw createValidationError("Query parameter 'sequence' must be a positive integer."); + } + + const sequence = Number(value); + if (sequence <= 0 || !Number.isSafeInteger(sequence)) { + throw createValidationError("Query parameter 'sequence' must be a positive integer."); + } + + return sequence; +} + +/** + * GET /utils/ledger-date?sequence={sequence} + * Estimate the approximate date and time a Stellar ledger sequence was closed. + */ +router.get("/ledger-date", async (req, res, next) => { + try { + const sequence = parseLedgerSequence(req.query.sequence); + const latestResponse = await server.ledgers().order("desc").limit(1).call(); + const latestLedger = latestResponse.records && latestResponse.records[0]; + + if (!latestLedger || !latestLedger.closed_at || !latestLedger.sequence) { + throw new Error("Unable to determine the latest ledger from Horizon."); + } + + const latestSequence = Number(latestLedger.sequence); + const latestClosedAt = new Date(latestLedger.closed_at); + const sequenceDelta = latestSequence - sequence; + const estimatedDate = new Date( + latestClosedAt.getTime() - sequenceDelta * AVERAGE_LEDGER_CLOSE_SECONDS * 1000, + ); + + return success(res, { + sequence, + estimatedDate: estimatedDate.toISOString(), + note: "This date is an approximation based on an average Stellar ledger close time of ~5 seconds.", + }); + } catch (err) { + next(err); + } +}); + /** * GET /utils/validate-asset?code={code} * Validate whether a given string is a valid Stellar asset code. diff --git a/tests/api.test.js b/tests/api.test.js index 60c7ce3..5e0f0b4 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -842,6 +842,56 @@ describe("StellarKit API", () => { }); }); + describe("GET /utils/ledger-date", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns estimated ledger close date for a valid sequence", async () => { + const stellarConfig = require("../src/config/stellar"); + jest.spyOn(stellarConfig.server, "ledgers").mockReturnValue({ + order: () => ({ + limit: () => ({ + call: async () => ({ + records: [ + { + sequence: "12350", + closed_at: "2026-06-02T12:00:00Z", + }, + ], + }), + }), + }), + }); + + const res = await request(app).get("/utils/ledger-date?sequence=12345"); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty("sequence", 12345); + expect(res.body.data).toHaveProperty("estimatedDate"); + expect(res.body.data.estimatedDate).toBe("2026-06-02T11:59:35.000Z"); + expect(res.body.data).toHaveProperty("note"); + expect(res.body.data.note).toContain("approximation"); + }); + + it("returns 400 for non-positive sequence values", async () => { + const res = await request(app).get("/utils/ledger-date?sequence=0"); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.type).toBe("ValidationError"); + }); + + it("returns 400 for invalid sequence format", async () => { + const res = await request(app).get("/utils/ledger-date?sequence=abc"); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.type).toBe("ValidationError"); + }); + }); + // ── DEX Price ────────────────────────────────────────────────────────────── describe("GET /dex/price/:sellAsset/:buyAsset", () => { const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";