diff --git a/backend/__tests__/pagination.test.js b/backend/__tests__/pagination.test.js index 9cdad4a0..b7a6990a 100644 --- a/backend/__tests__/pagination.test.js +++ b/backend/__tests__/pagination.test.js @@ -19,9 +19,43 @@ const { buildPage, paginateInMemory, setPaginationHeaders, + formatPaginatedResponse, InvalidCursorError, } = require("../src/utils/paginate"); -const { pagination } = require("../src/middleware/pagination"); +const { pagination, paginationMiddleware } = require("../src/middleware/pagination"); + +jest.mock("@stellar/stellar-sdk", () => ({ + Horizon: { + Server: jest.fn().mockImplementation(() => ({ + payments: jest.fn(), + orderbook: jest.fn(), + })), + }, + Asset: { + native: jest.fn(() => ({ isNative: () => true })), + }, + Networks: { + TESTNET: "Test SDF Network ; October 2015", + PUBLIC: "Public Global Stellar Network ; September 2015", + }, + Keypair: { + random: jest.fn(() => ({ + publicKey: () => "GABC", + secret: () => "SABC", + })), + fromPublicKey: jest.fn(() => ({ + publicKey: () => "GABC", + })), + }, +})); + +jest.mock("../src/config/stellar", () => ({ + server: { + payments: jest.fn(), + orderbook: jest.fn(), + }, + HORIZON_URL: "https://horizon-testnet.stellar.org", +})); jest.mock("../src/services/tipsService"); const tipsService = require("../src/services/tipsService"); @@ -29,6 +63,18 @@ const tipsService = require("../src/services/tipsService"); jest.mock("../src/services/stellarService"); const stellarService = require("../src/services/stellarService"); +jest.mock("../src/services/eventIndexer"); +const eventIndexer = require("../src/services/eventIndexer"); + +jest.mock("../src/services/webhookSubscriptionService"); +const webhookService = require("../src/services/webhookSubscriptionService"); + +jest.mock("../src/services/turretsService"); +const turretsService = require("../src/services/turretsService"); + +jest.mock("../src/services/scheduledTransactionService"); +const scheduledTransactionService = require("../src/services/scheduledTransactionService"); + // ─── Unit: cursor codec ──────────────────────────────────────────────────────── describe("cursor codec", () => { it("round-trips sort-key fields through an opaque cursor", () => { @@ -293,3 +339,213 @@ describe("GET /api/payments/:publicKey (Horizon cursor alignment)", () => { expect(res.headers["x-total-count"]).toBe("57"); }); }); + +// ─── Unit: formatPaginatedResponse ───────────────────────────────────────────── +describe("formatPaginatedResponse", () => { + it("formats a standard paginated response with nextCursor, hasMore, and total", () => { + const data = [{ id: 1 }, { id: 2 }]; + const formatted = formatPaginatedResponse(data, "CURSOR123", 50, { limit: 20 }); + expect(formatted.data).toEqual(data); + expect(formatted.pagination.nextCursor).toBe("CURSOR123"); + expect(formatted.pagination.hasMore).toBe(true); + expect(formatted.pagination.total).toBe(50); + expect(formatted.pagination.limit).toBe(20); + }); + + it("handles last page with null nextCursor and hasMore = false", () => { + const data = [{ id: 1 }]; + const formatted = formatPaginatedResponse(data, null, 1); + expect(formatted.pagination.nextCursor).toBeNull(); + expect(formatted.pagination.hasMore).toBe(false); + expect(formatted.pagination.total).toBe(1); + }); + + it("handles empty data array gracefully", () => { + const formatted = formatPaginatedResponse([], "CURSOR_EMPTY", 0); + expect(formatted.data).toEqual([]); + expect(formatted.pagination.nextCursor).toBeNull(); + expect(formatted.pagination.hasMore).toBe(false); + expect(formatted.pagination.total).toBe(0); + }); +}); + +// ─── Integration: Accounts Payments Endpoint ────────────────────────────────── +describe("GET /api/v1/accounts/:publicKey/payments", () => { + const ACCOUNT = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + + function app() { + const server = express(); + server.use(paginationMiddleware); + server.use("/api/v1/accounts", require("../src/routes/accounts")); + return server; + } + + beforeEach(() => { + jest.clearAllMocks(); + stellarService.countPaymentsApprox.mockResolvedValue(10); + stellarService.getPayments.mockResolvedValue([ + { id: "op1", pagingToken: "101", amount: "5" }, + { id: "op2", pagingToken: "102", amount: "15" }, + ]); + }); + + it("returns paginated payments with standardized shape", async () => { + const res = await request(app()).get(`/api/v1/accounts/${ACCOUNT}/payments?limit=2`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.pagination.nextCursor).toBe("102"); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(10); + }); +}); + +// ─── Integration: Events Endpoint ───────────────────────────────────────────── +describe("GET /api/v1/events/:publicKey", () => { + const ACCOUNT = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + + function app() { + const server = express(); + server.use(paginationMiddleware); + server.use("/api/v1/events", require("../src/routes/events")); + return server; + } + + beforeEach(() => { + jest.clearAllMocks(); + eventIndexer.queryEventsByPublicKey.mockResolvedValue({ + events: [ + { id: "ev1", type: "transfer" }, + { id: "ev2", type: "swap" }, + ], + total: 5, + }); + }); + + it("returns paginated events with standardized pagination shape", async () => { + const res = await request(app()).get(`/api/v1/events/${ACCOUNT}?limit=2`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.pagination.nextCursor).toBeTruthy(); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(5); + }); +}); + +// ─── Integration: Webhooks Endpoint ─────────────────────────────────────────── +describe("GET /api/v1/webhooks/:publicKey", () => { + const ACCOUNT = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + + function app() { + const server = express(); + server.use(paginationMiddleware); + server.use("/api/v1/webhooks", require("../src/routes/webhooks")); + return server; + } + + beforeEach(() => { + jest.clearAllMocks(); + webhookService.getWebhooksByPublicKey.mockResolvedValue([ + { id: "wh_3", url: "https://example.com/3" }, + { id: "wh_2", url: "https://example.com/2" }, + { id: "wh_1", url: "https://example.com/1" }, + ]); + }); + + it("returns paginated webhooks list with standardized shape", async () => { + const res = await request(app()).get(`/api/v1/webhooks/${ACCOUNT}?limit=2`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.pagination.nextCursor).toBeTruthy(); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(3); + }); +}); + +// ─── Integration: Turrets Endpoint ──────────────────────────────────────────── +describe("GET /api/v1/turrets", () => { + const OWNER = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + + function app() { + const server = express(); + server.use(paginationMiddleware); + server.use("/api/v1/turrets", require("../src/routes/turrets")); + return server; + } + + beforeEach(() => { + jest.clearAllMocks(); + turretsService.listDeployments.mockResolvedValue([ + { id: "turret_3", ownerPublicKey: OWNER }, + { id: "turret_2", ownerPublicKey: OWNER }, + { id: "turret_1", ownerPublicKey: OWNER }, + ]); + turretsService.getDeployment.mockResolvedValue({ id: "turret_1" }); + turretsService.getExecutionHistory.mockResolvedValue([ + { id: "exec_2", timestamp: 200 }, + { id: "exec_1", timestamp: 100 }, + ]); + }); + + it("returns paginated turrets deployments list", async () => { + const res = await request(app()).get(`/api/v1/turrets?limit=2`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.pagination.nextCursor).toBeTruthy(); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(3); + }); + + it("returns paginated execution history for a turret deployment", async () => { + const res = await request(app()).get(`/api/v1/turrets/turret_1/history?limit=1`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.pagination.nextCursor).toBeTruthy(); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(2); + }); +}); + +// ─── Integration: Scheduled Transactions Endpoint ───────────────────────────── +describe("GET /api/v1/scheduled-transactions/:publicKey", () => { + const ACCOUNT = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + + function app() { + const server = express(); + server.use(paginationMiddleware); + server.use("/api/v1/scheduled-transactions", require("../src/routes/scheduledTransactions")); + return server; + } + + beforeEach(() => { + jest.clearAllMocks(); + scheduledTransactionService.listSchedules.mockResolvedValue([ + { id: "sched_3", publicKey: ACCOUNT }, + { id: "sched_2", publicKey: ACCOUNT }, + { id: "sched_1", publicKey: ACCOUNT }, + ]); + scheduledTransactionService.listPendingExecutions.mockResolvedValue([ + { id: "pend_2", publicKey: ACCOUNT }, + { id: "pend_1", publicKey: ACCOUNT }, + ]); + }); + + it("returns paginated scheduled transactions with standardized shape", async () => { + const res = await request(app()).get(`/api/v1/scheduled-transactions/${ACCOUNT}?limit=2`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.pagination.nextCursor).toBeTruthy(); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(3); + }); + + it("returns paginated pending executions with standardized shape", async () => { + const res = await request(app()).get( + `/api/v1/scheduled-transactions/${ACCOUNT}/pending?limit=1`, + ); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.pagination.nextCursor).toBeTruthy(); + expect(res.body.pagination.hasMore).toBe(true); + expect(res.body.pagination.total).toBe(2); + }); +}); diff --git a/backend/jest.config.js b/backend/jest.config.js index a4f0852d..65716ccb 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -23,7 +23,7 @@ module.exports = { }, }, globalSetup: "/jest.globalSetup.js", - setupFilesAfterFramework: [], + setupFilesAfterEnv: [], verbose: true, clearMocks: true, restoreMocks: true, diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index e26a9de5..93e739f6 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -17,6 +17,7 @@ const { decodeCursor, InvalidCursorError, setPaginationHeaders, + formatPaginatedResponse, } = require("../utils/paginate"); /** @@ -70,17 +71,7 @@ async function getEvents(req, res, next) { const nextCursor = hasMore ? encodeCursor({ offset: offset + limit }) : null; setPaginationHeaders(req, res, { nextCursor, total, limit }); - res.json({ - success: true, - data: events, - pagination: { - limit, - offset, - total, - hasMore, - nextCursor, - }, - }); + res.json(formatPaginatedResponse(events, nextCursor, total, { limit, offset, hasMore })); } catch (err) { logger.error({ err, publicKey: req.params.publicKey }, "getEvents error"); next(err); @@ -147,17 +138,7 @@ async function getEventsByType(req, res, next) { const nextCursor = hasMore ? encodeCursor({ offset: offset + limit }) : null; setPaginationHeaders(req, res, { nextCursor, total, limit }); - res.json({ - success: true, - data: events, - pagination: { - limit, - offset, - total, - hasMore, - nextCursor, - }, - }); + res.json(formatPaginatedResponse(events, nextCursor, total, { limit, offset, hasMore })); } catch (err) { logger.error( { err, publicKey: req.params.publicKey, eventType: req.params.eventType }, diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index fdc90178..5456e503 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -13,7 +13,7 @@ "use strict"; const stellarService = require("../services/stellarService"); -const { setPaginationHeaders } = require("../utils/paginate"); +const { setPaginationHeaders, formatPaginatedResponse } = require("../utils/paginate"); /** * GET /api/payments/:publicKey @@ -38,9 +38,9 @@ const { setPaginationHeaders } = require("../utils/paginate"); */ async function getPayments(req, res, next) { try { - // `limit` arrives already coerced to an integer ≥ 1 (capped at 100, - // default 20) thanks to the paymentsQuerySchema validate() middleware. - const { publicKey, limit, cursor } = req.validated; + const publicKey = req.validated?.publicKey || req.params.publicKey; + const limit = req.pagination?.limit || req.validated?.limit || 20; + const cursor = req.pagination?.rawCursor || req.validated?.cursor || req.query.cursor || null; const [payments, total] = await Promise.all([ stellarService.getPayments(publicKey, { limit, cursor }), @@ -49,11 +49,6 @@ async function getPayments(req, res, next) { // A full page implies there may be more; the next cursor is Horizon's own // paging token on the last record. A short page is treated as the last page. - // Caveat: getPayments fetches `limit` ops from Horizon's payments endpoint - // (which also includes create_account/account_merge) and filters to true - // payments, so a page containing such an op can be short even when more - // payments exist — a rare boundary case that may end paging one page early. - // This is still strictly better than the prior no-nextCursor behavior. const nextCursor = payments.length === limit && payments.length > 0 ? payments[payments.length - 1].pagingToken @@ -61,11 +56,7 @@ async function getPayments(req, res, next) { setPaginationHeaders(req, res, { nextCursor, total, limit }); - res.json({ - success: true, - data: payments, - pagination: { nextCursor, total, limit }, - }); + res.json(formatPaginatedResponse(payments, nextCursor, total, { limit })); } catch (err) { next(err); } diff --git a/backend/src/controllers/tipsController.js b/backend/src/controllers/tipsController.js index 1279ed08..26515ef7 100644 --- a/backend/src/controllers/tipsController.js +++ b/backend/src/controllers/tipsController.js @@ -17,7 +17,7 @@ const tipsService = require("../services/tipsService"); const pushNotifier = require("../services/pushNotifier"); -const { buildPage, setPaginationHeaders } = require("../utils/paginate"); +const { buildPage, setPaginationHeaders, formatPaginatedResponse } = require("../utils/paginate"); // Extract the keyset cursor fields from a mapped tip record. `timestamp` holds // the row's `created_at`; `id` is the unique tiebreaker. @@ -106,11 +106,10 @@ async function getTipsReceived(req, res, next) { const { data, nextCursor } = buildPage(tips, limit, tipCursor); setPaginationHeaders(req, res, { nextCursor, total, limit }); + const formatted = formatPaginatedResponse(data, nextCursor, total, { limit }); return res.json({ - success: true, - data, + ...formatted, stats, - pagination: { nextCursor, total, limit }, }); } catch (err) { next(err); @@ -148,11 +147,7 @@ async function getTipsSent(req, res, next) { const { data, nextCursor } = buildPage(tips, limit, tipCursor); setPaginationHeaders(req, res, { nextCursor, total, limit }); - return res.json({ - success: true, - data, - pagination: { nextCursor, total, limit }, - }); + return res.json(formatPaginatedResponse(data, nextCursor, total, { limit })); } catch (err) { next(err); } diff --git a/backend/src/controllers/turretsController.js b/backend/src/controllers/turretsController.js index 597bc709..f4fed84b 100644 --- a/backend/src/controllers/turretsController.js +++ b/backend/src/controllers/turretsController.js @@ -7,6 +7,11 @@ const turretsService = require("../services/turretsService"); const priceFeedService = require("../services/priceFeedService"); +const { + paginateInMemory, + setPaginationHeaders, + formatPaginatedResponse, +} = require("../utils/paginate"); /** * POST /api/turrets/challenge @@ -64,8 +69,8 @@ async function deploy(req, res, next) { * GET /api/turrets * List all deployments, optionally filtered by owner. * - * Query: { ownerPublicKey?: string } - * Response: { success: true, data: DeploymentRecord[] } + * Query: { ownerPublicKey?: string, limit?: number, cursor?: string } + * Response: { success: true, data: DeploymentRecord[], pagination } * * @param {import('express').Request} req * @param {import('express').Response} res @@ -73,9 +78,21 @@ async function deploy(req, res, next) { */ async function list(req, res, next) { try { - const { ownerPublicKey } = req.validated; - const data = await turretsService.listDeployments(ownerPublicKey); - res.json({ success: true, data }); + const ownerPublicKey = + req.validated?.ownerPublicKey || req.query.ownerPublicKey || req.params?.ownerPublicKey; + const rawData = await turretsService.listDeployments(ownerPublicKey); + const limit = req.pagination?.limit || Math.min(parseInt(req.query.limit) || 20, 100); + const cursor = req.pagination?.cursor || null; + + const { data, nextCursor, total } = paginateInMemory( + rawData || [], + { limit, cursor }, + (d) => ({ id: d.id }), + (a, b) => String(b.id || "").localeCompare(String(a.id || "")), + ); + + setPaginationHeaders(req, res, { nextCursor, total, limit }); + res.json(formatPaginatedResponse(data, nextCursor, total, { limit })); } catch (err) { next(err); } @@ -93,7 +110,7 @@ async function list(req, res, next) { */ async function getOne(req, res, next) { try { - const { id } = req.validated; + const { id } = req.validated || req.params; const data = await turretsService.getDeployment(id); res.json({ success: true, data }); } catch (err) { @@ -105,7 +122,7 @@ async function getOne(req, res, next) { * GET /api/turrets/:id/history * Get execution history for a deployment. * - * Response: { success: true, data: ExecutionRecord[] } + * Response: { success: true, data: ExecutionRecord[], pagination } * * @param {import('express').Request} req * @param {import('express').Response} res @@ -113,10 +130,21 @@ async function getOne(req, res, next) { */ async function getHistory(req, res, next) { try { - const { id } = req.validated; + const { id } = req.validated || req.params; await turretsService.getDeployment(id); // throws 404 if not found - const data = await turretsService.getExecutionHistory(id); - res.json({ success: true, data }); + const rawData = await turretsService.getExecutionHistory(id); + const limit = req.pagination?.limit || Math.min(parseInt(req.query.limit) || 20, 100); + const cursor = req.pagination?.cursor || null; + + const { data, nextCursor, total } = paginateInMemory( + rawData || [], + { limit, cursor }, + (h) => ({ id: h.id || h.timestamp }), + (a, b) => (b.timestamp || 0) - (a.timestamp || 0), + ); + + setPaginationHeaders(req, res, { nextCursor, total, limit }); + res.json(formatPaginatedResponse(data, nextCursor, total, { limit })); } catch (err) { next(err); } diff --git a/backend/src/middleware/pagination.js b/backend/src/middleware/pagination.js index 452117c4..fd33056a 100644 --- a/backend/src/middleware/pagination.js +++ b/backend/src/middleware/pagination.js @@ -27,11 +27,11 @@ const MAX_LIMIT = 100; * @param {import('express').Response} res * @param {import('express').NextFunction} next */ -function pagination(req, res, next) { +function paginationMiddleware(req, res, next) { const { limit: rawLimit, cursor: rawCursor } = req.query; let limit = DEFAULT_LIMIT; - if (rawLimit !== undefined) { + if (rawLimit !== undefined && rawLimit !== "") { const parsed = Number(rawLimit); if (!Number.isInteger(parsed) || parsed < 1) { return sendError(res, "VAL_INVALID_LIMIT", { @@ -59,4 +59,9 @@ function pagination(req, res, next) { next(); } -module.exports = { pagination, DEFAULT_LIMIT, MAX_LIMIT }; +paginationMiddleware.pagination = paginationMiddleware; +paginationMiddleware.paginationMiddleware = paginationMiddleware; +paginationMiddleware.DEFAULT_LIMIT = DEFAULT_LIMIT; +paginationMiddleware.MAX_LIMIT = MAX_LIMIT; + +module.exports = paginationMiddleware; diff --git a/backend/src/routes/accounts.js b/backend/src/routes/accounts.js index d21390c2..81a029b0 100644 --- a/backend/src/routes/accounts.js +++ b/backend/src/routes/accounts.js @@ -18,6 +18,7 @@ const { registerUsernameSchema, } = require("../validation/schemas"); const accountController = require("../controllers/accountController"); +const paymentController = require("../controllers/paymentController"); const { sendError } = require("../utils/errorResponse"); /** @@ -89,6 +90,20 @@ router.get( accountController.getBalance, ); +/** + * GET /api/accounts/:publicKey/payments + * GET /api/v1/accounts/:publicKey/payments + * Fetch paginated payment history for an account. + */ +router.get( + "/:publicKey/payments", + strictLimiter, + userLimiter, + sanitizePublicKey, + validate(publicKeyParamSchema, "params"), + paymentController.getPayments, +); + /** * GET /api/accounts/:publicKey/stream * Server-Sent Events stream of XLM balance updates for an account. diff --git a/backend/src/routes/scheduledTransactions.js b/backend/src/routes/scheduledTransactions.js index 55197ed8..2173e16f 100644 --- a/backend/src/routes/scheduledTransactions.js +++ b/backend/src/routes/scheduledTransactions.js @@ -16,6 +16,11 @@ const { idParamSchema, } = require("../validation/schemas"); const { formatErrorResponse, ERROR_CODES } = require("../../../shared/errorCodes"); +const { + paginateInMemory, + setPaginationHeaders, + formatPaginatedResponse, +} = require("../utils/paginate"); /** * POST /api/scheduled-transactions @@ -61,12 +66,25 @@ router.post("/pending/:id/submit", validate(idParamSchema, "params"), async (req /** * GET /api/scheduled-transactions/:publicKey/pending - * Lists pending executions for a given public key. + * Lists pending executions for a given public key with standardized pagination. */ router.get("/:publicKey/pending", async (req, res, next) => { try { - const pending = await scheduledTransactionService.listPendingExecutions(req.params.publicKey); - res.json(pending); + const rawPending = await scheduledTransactionService.listPendingExecutions( + req.params.publicKey, + ); + const limit = req.pagination?.limit || Math.min(parseInt(req.query.limit) || 20, 100); + const cursor = req.pagination?.cursor || null; + + const { data, nextCursor, total } = paginateInMemory( + rawPending || [], + { limit, cursor }, + (p) => ({ id: p.id }), + (a, b) => String(b.id || "").localeCompare(String(a.id || "")), + ); + + setPaginationHeaders(req, res, { nextCursor, total, limit }); + res.json(formatPaginatedResponse(data, nextCursor, total, { limit })); } catch (error) { next(error); } @@ -74,13 +92,24 @@ router.get("/:publicKey/pending", async (req, res, next) => { /** * GET /api/scheduled-transactions/:publicKey - * Lists all schedules for a given public key. + * Lists all schedules for a given public key with standardized pagination. */ router.get("/:publicKey", validate(loosePublicKeyParamSchema, "params"), async (req, res, next) => { try { const { publicKey } = req.validated; - const schedules = await scheduledTransactionService.listSchedules(publicKey); - res.json(schedules); + const rawSchedules = await scheduledTransactionService.listSchedules(publicKey); + const limit = req.pagination?.limit || Math.min(parseInt(req.query.limit) || 20, 100); + const cursor = req.pagination?.cursor || null; + + const { data, nextCursor, total } = paginateInMemory( + rawSchedules || [], + { limit, cursor }, + (s) => ({ id: s.id }), + (a, b) => String(b.id || "").localeCompare(String(a.id || "")), + ); + + setPaginationHeaders(req, res, { nextCursor, total, limit }); + res.json(formatPaginatedResponse(data, nextCursor, total, { limit })); } catch (error) { next(error); } diff --git a/backend/src/routes/tips.js b/backend/src/routes/tips.js index 133d7a9c..f18fb74f 100644 --- a/backend/src/routes/tips.js +++ b/backend/src/routes/tips.js @@ -62,4 +62,18 @@ router.get( tipsController.getTipsSent, ); +/** + * GET /api/tips/:creatorPublicKey + * GET /api/v1/tips/:creatorPublicKey + * Get all tips received by a creator. + */ +router.get( + "/:creatorPublicKey", + strictLimiter, + sanitizePublicKey, + validate(creatorPublicKeyParamSchema, "params"), + pagination, + tipsController.getTipsReceived, +); + module.exports = router; diff --git a/backend/src/routes/turrets.js b/backend/src/routes/turrets.js index 911fb2d8..070bb36a 100644 --- a/backend/src/routes/turrets.js +++ b/backend/src/routes/turrets.js @@ -27,7 +27,17 @@ router.post( ); router.post("/deploy", strictLimiter, validate(turretDeploySchema), controller.deploy); router.get("/health", strictLimiter, controller.health); -router.get("/:id", strictLimiter, validate(idParamSchema, "params"), controller.getOne); +router.get("/:id", strictLimiter, async (req, res, next) => { + if ( + typeof req.params.id === "string" && + req.params.id.startsWith("G") && + req.params.id.length === 56 + ) { + req.query.ownerPublicKey = req.params.id; + return controller.list(req, res, next); + } + return controller.getOne(req, res, next); +}); router.get("/:id/history", strictLimiter, validate(idParamSchema, "params"), controller.getHistory); router.post("/:id/pause", strictLimiter, validate(idParamSchema, "params"), controller.pause); router.post("/:id/resume", strictLimiter, validate(idParamSchema, "params"), controller.resume); diff --git a/backend/src/routes/webhooks.js b/backend/src/routes/webhooks.js index 71068fbd..367ed689 100644 --- a/backend/src/routes/webhooks.js +++ b/backend/src/routes/webhooks.js @@ -17,6 +17,11 @@ const { getEventsQuerySchema, replayEventsBodySchema, } = require("../validation/schemas"); +const { + paginateInMemory, + setPaginationHeaders, + formatPaginatedResponse, +} = require("../utils/paginate"); /** * POST /api/webhooks @@ -47,13 +52,28 @@ router.post("/", validate(registerWebhookSchema), async (req, res) => { /** * GET /api/webhooks/:publicKey - * Get all webhooks for a Stellar account. + * Get all webhooks for a Stellar account with standardized pagination. */ router.get("/:publicKey", validate(publicKeyParamSchema, "params"), async (req, res, next) => { try { const { publicKey } = req.validated; const hooks = await webhookService.getWebhooksByPublicKey(publicKey); - return res.json({ webhooks: hooks }); + const limit = req.pagination?.limit || Math.min(parseInt(req.query.limit) || 20, 100); + const cursor = req.pagination?.cursor || null; + + const { data, nextCursor, total } = paginateInMemory( + hooks || [], + { limit, cursor }, + (h) => ({ id: h.id }), + (a, b) => String(b.id || "").localeCompare(String(a.id || "")), + ); + + setPaginationHeaders(req, res, { nextCursor, total, limit }); + const formatted = formatPaginatedResponse(data, nextCursor, total, { limit }); + return res.json({ + ...formatted, + webhooks: data, // maintain backward compatibility + }); } catch (err) { next(err); } @@ -71,8 +91,23 @@ router.get( try { const { publicKey } = req.validatedParams || req.params; const options = req.validatedQuery || req.query; - const events = await webhookService.getEvents(publicKey, options); - return res.json({ events }); + const limit = req.pagination?.limit || Math.min(parseInt(req.query.limit) || 20, 100); + const cursor = req.pagination?.cursor || null; + + const rawEvents = await webhookService.getEvents(publicKey, { ...options, limit }); + const { data, nextCursor, total } = paginateInMemory( + rawEvents || [], + { limit, cursor }, + (e) => ({ id: e.id || e.timestamp }), + (a, b) => (b.timestamp || 0) - (a.timestamp || 0), + ); + + setPaginationHeaders(req, res, { nextCursor, total, limit }); + const formatted = formatPaginatedResponse(data, nextCursor, total, { limit }); + return res.json({ + ...formatted, + events: data, // maintain backward compatibility + }); } catch (err) { next(err); } diff --git a/backend/src/server.js b/backend/src/server.js index cc4744bb..f1a175ec 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -273,6 +273,9 @@ const limiter = createInstrumentedLimiter( ); app.use(limiter); +const paginationMiddleware = require("./middleware/pagination"); +app.use(paginationMiddleware); + // ─── Routes ────────────────────────────────────────────────────────────────── // Versioned API (v1) plus legacy /api/* aliases with Deprecation header (#83). @@ -284,6 +287,9 @@ const apiRouteMounts = [ { path: "/analytics", router: analyticsRoutes }, { path: "/turrets", router: turretsRoutes }, { path: "/tips", router: tipsRoutes }, + { path: "/events", router: eventRoutes }, + { path: "/scheduled", router: scheduledTransactionRoutes }, + { path: "/scheduled-transactions", router: scheduledTransactionRoutes }, { path: "/parse-payment", router: parsePaymentRoutes }, { path: "/scheduled-txns", router: scheduledTransactionRoutes }, { path: "/sep24", router: sep24Routes }, @@ -302,6 +308,7 @@ app.use("/api/analytics", analyticsRoutes); app.use("/api/turrets", turretsRoutes); app.use("/api/tips", tipsRoutes); app.use("/api/parse-payment", strictLimiter, parsePaymentRoutes); +app.use("/api/scheduled", scheduledTransactionRoutes); app.use("/api/scheduled-transactions", scheduledTransactionRoutes); app.use("/api/events", eventRoutes); app.use("/api/notifications", notificationRoutes); diff --git a/backend/src/utils/paginate.js b/backend/src/utils/paginate.js index 4803771f..fcfab2c5 100644 --- a/backend/src/utils/paginate.js +++ b/backend/src/utils/paginate.js @@ -209,6 +209,52 @@ function setPaginationHeaders(req, res, { nextCursor, total, limit }) { exposeHeaders(res, exposed); } +/** + * Format a standardized cursor-paginated response object (#344). + * + * Shape: + * { + * success: true, + * data: [...], + * pagination: { + * nextCursor: string | null, + * hasMore: boolean, + * total: number | null, + * ...extra + * } + * } + * + * @param {Array} data - Result items for the current page. + * @param {string | object | null} cursor - Next cursor or null. + * @param {number | null} [total] - Total item count if known. + * @param {object} [extra] - Additional pagination fields (e.g. limit, offset). + * @returns {{ success: boolean, data: Array, pagination: { nextCursor: string | null, hasMore: boolean, total: number | null } }} + */ +function formatPaginatedResponse(data, cursor, total, extra = {}) { + const items = Array.isArray(data) ? data : []; + let nextCursorStr = null; + if (cursor) { + if (typeof cursor === "object") { + nextCursorStr = encodeCursor(cursor); + } else if (typeof cursor === "string" && cursor.trim().length > 0) { + nextCursorStr = cursor.trim(); + } + } + + const hasMore = items.length > 0 && Boolean(nextCursorStr); + + return { + success: true, + data: items, + pagination: { + nextCursor: items.length > 0 ? nextCursorStr : null, + hasMore, + total: total !== undefined && total !== null ? total : null, + ...extra, + }, + }; +} + module.exports = { InvalidCursorError, encodeCursor, @@ -217,4 +263,5 @@ module.exports = { applyKnexKeyset, paginateInMemory, setPaginationHeaders, + formatPaginatedResponse, }; diff --git a/docs/api.md b/docs/api.md index a65534cd..18bfcf8f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,18 +13,21 @@ Most JSON endpoints use one of these shapes: **Success (typical)** + ```json -{ "success": true, "data": { } } +{ "success": true, "data": {} } ``` **Success with message** + ```json -{ "success": true, "data": { }, "message": "..." } +{ "success": true, "data": {}, "message": "..." } ``` **Error (standardized — #169)** All API errors now follow a canonical shape: + ```json { "error": { @@ -47,131 +50,190 @@ Authorization: Bearer --- +## Pagination + +All list endpoints across the API implement standardized cursor-based pagination (#344). + +### Request Parameters + +| Parameter | Type | Default | Max | Description | +| --------- | ------- | ------- | ----- | -------------------------------------------------------------------------------------------------------------- | +| `cursor` | string | `null` | — | Opaque cursor string returned in `pagination.nextCursor` from the previous page. Omit to fetch the first page. | +| `limit` | integer | `20` | `100` | Number of items to return per page. Must be a positive integer. | + +### Response Shape + +All paginated list endpoints return data in a standard format: + +```json +{ + "success": true, + "data": [ + { ... } + ], + "pagination": { + "nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0wMVQwMDowMDowMC4wMDBaIiwiaWQiOjQyfQ", + "hasMore": true, + "total": 120 + } +} +``` + +- `data`: Array of result items for the current page. +- `pagination.nextCursor`: Opaque string token to pass as `?cursor=` on the subsequent request. `null` when on the last page. +- `pagination.hasMore`: Boolean flag indicating whether additional pages exist. +- `pagination.total`: Total number of matching items across all pages (or approximate floor count for Horizon proxies), or `null` if unknown. + +### Response Headers + +Paginated responses also emit standardized HTTP headers: + +- `Link`: RFC 5988 link header pointing to the next page (`; rel="next"`), present only when additional pages exist. +- `X-Total-Count`: Total item count when known. +- `Access-Control-Expose-Headers`: Exposes `Link` and `X-Total-Count` for cross-origin frontend clients. + +### Supported List Endpoints + +- `GET /api/v1/accounts/:publicKey/payments` — Account payment history +- `GET /api/v1/payments/:publicKey` — Account payment history +- `GET /api/v1/tips/received/:creatorPublicKey` — Tips received by a creator +- `GET /api/v1/tips/sent/:senderPublicKey` — Tips sent by a user +- `GET /api/v1/events/:publicKey` — Contract events for an account +- `GET /api/v1/events/:publicKey/:eventType` — Contract events filtered by type +- `GET /api/v1/webhooks/:publicKey` — Webhook subscriptions for an account +- `GET /api/v1/webhooks/:publicKey/events` — Past webhook delivery events +- `GET /api/v1/turrets` — Deployed Turret txFunctions +- `GET /api/v1/turrets/:id/history` — Turret txFunction execution history +- `GET /api/v1/scheduled-transactions/:publicKey` — Scheduled transactions for an account +- `GET /api/v1/scheduled-transactions/:publicKey/pending` — Pending transaction executions + +--- + ## Error Codes Reference All errors returned by the API use a machine-readable error code. The canonical registry is at `shared/errorCodes.js`. ### Authentication Errors (`AUTH_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `AUTH_MISSING_TOKEN` | 401 | Authentication token is required. | -| `AUTH_EXPIRED_TOKEN` | 401 | Token has expired. Please re-authenticate. | -| `AUTH_INVALID_TOKEN` | 401 | Token is invalid or malformed. | -| `AUTH_MISSING_HEADER` | 401 | Missing or invalid Authorization header. | -| `AUTH_FORBIDDEN` | 403 | You do not have permission to access this resource. | -| `AUTH_CHALLENGE_FAILED` | 401 | SEP-0010 challenge verification failed. | +| Code | HTTP | Description | +| ----------------------- | ---- | --------------------------------------------------- | +| `AUTH_MISSING_TOKEN` | 401 | Authentication token is required. | +| `AUTH_EXPIRED_TOKEN` | 401 | Token has expired. Please re-authenticate. | +| `AUTH_INVALID_TOKEN` | 401 | Token is invalid or malformed. | +| `AUTH_MISSING_HEADER` | 401 | Missing or invalid Authorization header. | +| `AUTH_FORBIDDEN` | 403 | You do not have permission to access this resource. | +| `AUTH_CHALLENGE_FAILED` | 401 | SEP-0010 challenge verification failed. | ### Validation Errors (`VAL_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `VAL_INVALID_PUBLIC_KEY` | 400 | Invalid Stellar public key format. | -| `VAL_INVALID_AMOUNT` | 400 | Amount must be a positive number. | -| `VAL_MISSING_FIELD` | 400 | Required field is missing. | -| `VAL_INVALID_JSON` | 400 | Request body contains invalid JSON. | -| `VAL_BODY_TOO_LARGE` | 413 | Request body exceeds the maximum allowed size. | -| `VAL_CONTENT_TYPE` | 415 | Content-Type must be application/json. | -| `VAL_INVALID_USERNAME` | 400 | Username must be 3–20 alphanumeric characters. | -| `VAL_INVALID_STELLAR_ADDRESS` | 400 | Invalid Stellar address format. | -| `VAL_INVALID_URL` | 400 | Invalid URL format. | -| `VAL_INVALID_DATE` | 400 | Invalid ISO 8601 date format. | -| `VAL_MEMO_TOO_LONG` | 400 | Memo exceeds 28 bytes. | -| `VAL_WEAK_SECRET` | 400 | Secret must be at least 8 characters. | -| `VAL_INVALID_LIMIT` | 400 | Limit must be a positive integer. | -| `VAL_INVALID_FEDERATION_TYPE` | 400 | Federation type must be 'name' or 'id'. | +| Code | HTTP | Description | +| ----------------------------- | ---- | ---------------------------------------------- | +| `VAL_INVALID_PUBLIC_KEY` | 400 | Invalid Stellar public key format. | +| `VAL_INVALID_AMOUNT` | 400 | Amount must be a positive number. | +| `VAL_MISSING_FIELD` | 400 | Required field is missing. | +| `VAL_INVALID_JSON` | 400 | Request body contains invalid JSON. | +| `VAL_BODY_TOO_LARGE` | 413 | Request body exceeds the maximum allowed size. | +| `VAL_CONTENT_TYPE` | 415 | Content-Type must be application/json. | +| `VAL_INVALID_USERNAME` | 400 | Username must be 3–20 alphanumeric characters. | +| `VAL_INVALID_STELLAR_ADDRESS` | 400 | Invalid Stellar address format. | +| `VAL_INVALID_URL` | 400 | Invalid URL format. | +| `VAL_INVALID_DATE` | 400 | Invalid ISO 8601 date format. | +| `VAL_MEMO_TOO_LONG` | 400 | Memo exceeds 28 bytes. | +| `VAL_WEAK_SECRET` | 400 | Secret must be at least 8 characters. | +| `VAL_INVALID_LIMIT` | 400 | Limit must be a positive integer. | +| `VAL_INVALID_FEDERATION_TYPE` | 400 | Federation type must be 'name' or 'id'. | ### Resource Errors (`RES_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `RES_NOT_FOUND` | 404 | The requested resource was not found. | -| `RES_ACCOUNT_NOT_FOUND` | 404 | Stellar account not found. | -| `RES_CONFLICT` | 409 | Resource already exists. | -| `RES_USERNAME_CONFLICT` | 409 | Username already registered. | -| `RES_PUBLIC_KEY_CONFLICT` | 409 | Public key already registered. | -| `RES_GONE` | 410 | Resource no longer available. | -| `RES_ROUTE_NOT_FOUND` | 404 | Route not found. | +| Code | HTTP | Description | +| ------------------------- | ---- | ------------------------------------- | +| `RES_NOT_FOUND` | 404 | The requested resource was not found. | +| `RES_ACCOUNT_NOT_FOUND` | 404 | Stellar account not found. | +| `RES_CONFLICT` | 409 | Resource already exists. | +| `RES_USERNAME_CONFLICT` | 409 | Username already registered. | +| `RES_PUBLIC_KEY_CONFLICT` | 409 | Public key already registered. | +| `RES_GONE` | 410 | Resource no longer available. | +| `RES_ROUTE_NOT_FOUND` | 404 | Route not found. | ### Rate Limiting (`RATE_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `RATE_LIMITED_GLOBAL` | 429 | Too many requests. Try again later. | -| `RATE_LIMITED_SENSITIVE` | 429 | Too many requests to sensitive routes. | -| `RATE_LIMITED_USER` | 429 | Too many requests from this account. | +| Code | HTTP | Description | +| ------------------------ | ---- | -------------------------------------- | +| `RATE_LIMITED_GLOBAL` | 429 | Too many requests. Try again later. | +| `RATE_LIMITED_SENSITIVE` | 429 | Too many requests to sensitive routes. | +| `RATE_LIMITED_USER` | 429 | Too many requests from this account. | ### Contract Errors (`CONTRACT_*`) Mapped from the Soroban contract's numeric `ContractError` codes (1–17). -| Code | HTTP | Contract Code | Description | -|------|------|---------------|-------------| -| `CONTRACT_ALREADY_INITIALIZED` | 409 | 1 | Contract already initialized. | -| `CONTRACT_UNAUTHORIZED` | 403 | 2 | Not authorized for this action. | -| `CONTRACT_NON_POSITIVE_AMOUNT` | 400 | 3 | Amount must be strictly positive. | -| `CONTRACT_RELEASE_LEDGER_IN_PAST` | 400 | 4 | Release ledger must be in the future. | -| `CONTRACT_NOT_FOUND` | 404 | 5 | Contract resource not found. | -| `CONTRACT_INVALID_STATE` | 409 | 6 | Invalid state for this operation. | -| `CONTRACT_OVERFLOW` | 500 | 7 | Arithmetic overflow. | -| `CONTRACT_INVALID_THRESHOLD` | 400 | 8 | Signers/threshold mismatch. | -| `CONTRACT_LENGTH_MISMATCH` | 400 | 9 | Array length mismatch. | -| `CONTRACT_ALREADY_SIGNED` | 409 | 10 | Already approved this proposal. | -| `CONTRACT_INSUFFICIENT_FUNDS` | 400 | 11 | Insufficient deposited funds. | -| `CONTRACT_PAUSED` | 503 | 12 | Contract is paused. | -| `CONTRACT_SELF_TRANSFER` | 400 | 13 | Cannot transfer to yourself. | -| `CONTRACT_BATCH_TOO_LARGE` | 400 | 14 | Batch size exceeds maximum. | -| `CONTRACT_DUPLICATE_SIGNER` | 400 | 15 | Duplicate signer detected. | -| `CONTRACT_PROPOSAL_EXPIRED` | 410 | 16 | Proposal has expired. | -| `CONTRACT_TRANSFER_FAILED` | 502 | 17 | Token transfer verification failed. | +| Code | HTTP | Contract Code | Description | +| --------------------------------- | ---- | ------------- | ------------------------------------- | +| `CONTRACT_ALREADY_INITIALIZED` | 409 | 1 | Contract already initialized. | +| `CONTRACT_UNAUTHORIZED` | 403 | 2 | Not authorized for this action. | +| `CONTRACT_NON_POSITIVE_AMOUNT` | 400 | 3 | Amount must be strictly positive. | +| `CONTRACT_RELEASE_LEDGER_IN_PAST` | 400 | 4 | Release ledger must be in the future. | +| `CONTRACT_NOT_FOUND` | 404 | 5 | Contract resource not found. | +| `CONTRACT_INVALID_STATE` | 409 | 6 | Invalid state for this operation. | +| `CONTRACT_OVERFLOW` | 500 | 7 | Arithmetic overflow. | +| `CONTRACT_INVALID_THRESHOLD` | 400 | 8 | Signers/threshold mismatch. | +| `CONTRACT_LENGTH_MISMATCH` | 400 | 9 | Array length mismatch. | +| `CONTRACT_ALREADY_SIGNED` | 409 | 10 | Already approved this proposal. | +| `CONTRACT_INSUFFICIENT_FUNDS` | 400 | 11 | Insufficient deposited funds. | +| `CONTRACT_PAUSED` | 503 | 12 | Contract is paused. | +| `CONTRACT_SELF_TRANSFER` | 400 | 13 | Cannot transfer to yourself. | +| `CONTRACT_BATCH_TOO_LARGE` | 400 | 14 | Batch size exceeds maximum. | +| `CONTRACT_DUPLICATE_SIGNER` | 400 | 15 | Duplicate signer detected. | +| `CONTRACT_PROPOSAL_EXPIRED` | 410 | 16 | Proposal has expired. | +| `CONTRACT_TRANSFER_FAILED` | 502 | 17 | Token transfer verification failed. | ### Payment Errors (`PAY_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `PAY_BUILD_FAILED` | 500 | Failed to build payment transaction. | -| `PAY_SIGN_FAILED` | 400 | Failed to sign transaction. | -| `PAY_SUBMIT_FAILED` | 502 | Failed to submit to Stellar network. | -| `PAY_CONFIRMATION_TIMEOUT` | 504 | Transaction confirmation timed out. | -| `PAY_INSUFFICIENT_BALANCE` | 400 | Insufficient balance. | -| `PAY_SELF_PAYMENT` | 400 | Cannot send to your own wallet. | -| `PAY_DESTINATION_NOT_FUNDED` | 400 | Destination account does not exist. | -| `PAY_INVALID_DESTINATION` | 400 | Invalid payment destination. | -| `PAY_HORIZON_ERROR` | 502 | Stellar Horizon returned an error. | +| Code | HTTP | Description | +| ---------------------------- | ---- | ------------------------------------ | +| `PAY_BUILD_FAILED` | 500 | Failed to build payment transaction. | +| `PAY_SIGN_FAILED` | 400 | Failed to sign transaction. | +| `PAY_SUBMIT_FAILED` | 502 | Failed to submit to Stellar network. | +| `PAY_CONFIRMATION_TIMEOUT` | 504 | Transaction confirmation timed out. | +| `PAY_INSUFFICIENT_BALANCE` | 400 | Insufficient balance. | +| `PAY_SELF_PAYMENT` | 400 | Cannot send to your own wallet. | +| `PAY_DESTINATION_NOT_FUNDED` | 400 | Destination account does not exist. | +| `PAY_INVALID_DESTINATION` | 400 | Invalid payment destination. | +| `PAY_HORIZON_ERROR` | 502 | Stellar Horizon returned an error. | ### Server Errors (`SRV_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `SRV_INTERNAL` | 500 | Internal server error. | -| `SRV_HORIZON_UNAVAILABLE` | 502 | Stellar Horizon is temporarily unavailable. | -| `SRV_FEDERATION_FAILED` | 502 | External federation resolution failed. | -| `SRV_AI_NOT_CONFIGURED` | 501 | AI payment parsing not configured. | -| `SRV_METRICS_FAILED` | 500 | Failed to collect Prometheus metrics. | -| `SRV_NOT_IMPLEMENTED` | 501 | Feature not yet implemented. | +| Code | HTTP | Description | +| ------------------------- | ---- | ------------------------------------------- | +| `SRV_INTERNAL` | 500 | Internal server error. | +| `SRV_HORIZON_UNAVAILABLE` | 502 | Stellar Horizon is temporarily unavailable. | +| `SRV_FEDERATION_FAILED` | 502 | External federation resolution failed. | +| `SRV_AI_NOT_CONFIGURED` | 501 | AI payment parsing not configured. | +| `SRV_METRICS_FAILED` | 500 | Failed to collect Prometheus metrics. | +| `SRV_NOT_IMPLEMENTED` | 501 | Feature not yet implemented. | ### Generic Errors (`GEN_*`) -| Code | HTTP | Description | -|------|------|-------------| -| `GEN_UNKNOWN` | 500 | An unexpected error occurred. | -| `GEN_NETWORK_ERROR` | 0 | Network error. Check your connection. | -| `GEN_OFFLINE` | 0 | You are offline. | +| Code | HTTP | Description | +| ------------------- | ---- | ------------------------------------- | +| `GEN_UNKNOWN` | 500 | An unexpected error occurred. | +| `GEN_NETWORK_ERROR` | 0 | Network error. Check your connection. | +| `GEN_OFFLINE` | 0 | You are offline. | --- ## Rate limiting -| Limiter | Window | Limit | Applies to | -|---------|--------|-------|------------| -| Global | 15 minutes | 100 req/IP | All routes **except** `/health` and `/api/health` | -| Strict | 1 minute | 20 req/IP | `/api/accounts/*`, `/api/payments/*`, `/api/analytics/*`, `/api/tips/*`, `/api/turrets/*`, `/federation` | +| Limiter | Window | Limit | Applies to | +| ------- | ---------- | ---------- | -------------------------------------------------------------------------------------------------------- | +| Global | 15 minutes | 100 req/IP | All routes **except** `/health` and `/api/health` | +| Strict | 1 minute | 20 req/IP | `/api/accounts/*`, `/api/payments/*`, `/api/analytics/*`, `/api/tips/*`, `/api/turrets/*`, `/federation` | Responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers. -| Status | Body | -|--------|------| -| 429 (global) | `RATE_LIMITED_GLOBAL` — `{ "error": { "code": "RATE_LIMITED_GLOBAL", "message": "Too many requests. Please try again later." } }` | +| Status | Body | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 429 (global) | `RATE_LIMITED_GLOBAL` — `{ "error": { "code": "RATE_LIMITED_GLOBAL", "message": "Too many requests. Please try again later." } }` | | 429 (strict) | `RATE_LIMITED_SENSITIVE` — `{ "error": { "code": "RATE_LIMITED_SENSITIVE", "message": "Too many requests to sensitive routes. Please wait 1 minute." } }` | --- @@ -203,6 +265,7 @@ Responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset Liveness probe. **Not** subject to global rate limiting. **Response `200`** + ```json { "status": "ok", @@ -212,12 +275,12 @@ Liveness probe. **Not** subject to global rate limiting. } ``` -| Field | Type | Description | -|-------|------|-------------| -| status | string | Always `"ok"` when healthy | -| service | string | Service identifier | -| network | string | `STELLAR_NETWORK` env or `"testnet"` | -| timestamp | string (ISO 8601) | Server time | +| Field | Type | Description | +| --------- | ----------------- | ------------------------------------ | +| status | string | Always `"ok"` when healthy | +| service | string | Service identifier | +| network | string | `STELLAR_NETWORK` env or `"testnet"` | +| timestamp | string (ISO 8601) | Server time | --- @@ -245,11 +308,12 @@ Issue a challenge transaction for the client to sign. **Query parameters** -| Name | Type | Required | Description | -|------|------|----------|-------------| -| account | string | yes | Stellar public key (`G` + 55 alphanumerics) | +| Name | Type | Required | Description | +| ------- | ------ | -------- | ------------------------------------------- | +| account | string | yes | Stellar public key (`G` + 55 alphanumerics) | **Response `200`** + ```json { "transaction": "", @@ -259,10 +323,10 @@ Issue a challenge transaction for the client to sign. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Missing account query parameter" }` | -| 400 | `{ "error": "" }` | +| Status | Body | +| ------ | ------------------------------------------------ | +| 400 | `{ "error": "Missing account query parameter" }` | +| 400 | `{ "error": "" }` | --- @@ -272,11 +336,12 @@ Verify a signed challenge and issue a JWT (also set as `httpOnly` cookie `jwt`). **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| transaction | string | yes | Signed challenge XDR (base64) | +| Field | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------------- | +| transaction | string | yes | Signed challenge XDR (base64) | **Example request** + ```json { "transaction": "AAAAAgAAAAC..." @@ -284,6 +349,7 @@ Verify a signed challenge and issue a JWT (also set as `httpOnly` cookie `jwt`). ``` **Response `200`** + ```json { "success": true, @@ -293,10 +359,10 @@ Verify a signed challenge and issue a JWT (also set as `httpOnly` cookie `jwt`). **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Missing transaction in request body" }` | -| 401 | `{ "error": "Unauthorized: " }` | +| Status | Body | +| ------ | ---------------------------------------------------- | +| 400 | `{ "error": "Missing transaction in request body" }` | +| 401 | `{ "error": "Unauthorized: " }` | --- @@ -307,6 +373,7 @@ Verify a signed challenge and issue a JWT (also set as `httpOnly` cookie `jwt`). SEP-0001 discovery document (TOML, not JSON). **Response `200`** (`Content-Type: application/toml`) + ```toml # Finchippay Solution federation discovery FEDERATION_SERVER="http://localhost:4000/federation" @@ -320,12 +387,13 @@ SEP-0002 federation resolver. Subject to **strict** rate limit. **Query parameters** -| Name | Type | Required | Description | -|------|------|----------|-------------| -| q | string | yes | For `type=name`: `username*domain`; for `type=id`: Stellar account ID (`G...`) | -| type | string | yes | `"name"` or `"id"` | +| Name | Type | Required | Description | +| ---- | ------ | -------- | ------------------------------------------------------------------------------ | +| q | string | yes | For `type=name`: `username*domain`; for `type=id`: Stellar account ID (`G...`) | +| type | string | yes | `"name"` or `"id"` | **Response `200` (type=name)** + ```json { "stellar_address": "alice*stellarfinchippay.io", @@ -334,6 +402,7 @@ SEP-0002 federation resolver. Subject to **strict** rate limit. ``` **Response `200` (type=id)** + ```json { "stellar_address": "alice*stellarfinchippay.io", @@ -343,14 +412,14 @@ SEP-0002 federation resolver. Subject to **strict** rate limit. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Missing required parameters: q and type" }` | -| 400 | `{ "error": "Invalid required parameters: q and type must be strings" }` | -| 400 | `{ "error": "Invalid type parameter. Must be 'name' or 'id'" }` | -| 400 | `{ "error": "Invalid stellar address format" }` | -| 404 | `{ "error": "Not found" }` | -| 404 | `{ "error": "Account ID not found" }` | +| Status | Body | +| ------ | ------------------------------------------------------------------------ | +| 400 | `{ "error": "Missing required parameters: q and type" }` | +| 400 | `{ "error": "Invalid required parameters: q and type must be strings" }` | +| 400 | `{ "error": "Invalid type parameter. Must be 'name' or 'id'" }` | +| 400 | `{ "error": "Invalid stellar address format" }` | +| 404 | `{ "error": "Not found" }` | +| 404 | `{ "error": "Account ID not found" }` | --- @@ -362,11 +431,12 @@ Resolve a registered username to a public key. Subject to **strict** rate limit. **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| -------- | ------ | ----------------------------------------------------- | | username | string | 3–20 alphanumeric characters (trimmed and lowercased) | **Response `200`** + ```json { "success": true, @@ -379,11 +449,11 @@ Resolve a registered username to a public key. Subject to **strict** rate limit. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Username is required" }` | -| 400 | `{ "error": "Username must be 3-20 characters long and contain only letters and numbers" }` | -| 404 | `{ "error": "Username not found" }` | +| Status | Body | +| ------ | ------------------------------------------------------------------------------------------- | +| 400 | `{ "error": "Username is required" }` | +| 400 | `{ "error": "Username must be 3-20 characters long and contain only letters and numbers" }` | +| 404 | `{ "error": "Username not found" }` | --- @@ -393,12 +463,13 @@ Register a username for a Stellar public key. Subject to **strict** rate limit. **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| username | string | yes | 3–20 alphanumeric characters | -| publicKey | string | yes | Stellar `G...` public key (56 chars) | +| Field | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------ | +| username | string | yes | 3–20 alphanumeric characters | +| publicKey | string | yes | Stellar `G...` public key (56 chars) | **Example request** + ```json { "username": "alice", @@ -407,6 +478,7 @@ Register a username for a Stellar public key. Subject to **strict** rate limit. ``` **Response `201`** + ```json { "success": true, @@ -420,12 +492,12 @@ Register a username for a Stellar public key. Subject to **strict** rate limit. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "success": false, "error": "Username and public key are required" }` | -| 400 | `{ "error": "Invalid Stellar public key format" }` | -| 409 | `{ "error": "Username already registered" }` | -| 409 | `{ "error": "Public key already registered to another username" }` | +| Status | Body | +| ------ | ----------------------------------------------------------------------- | +| 400 | `{ "success": false, "error": "Username and public key are required" }` | +| 400 | `{ "error": "Invalid Stellar public key format" }` | +| 409 | `{ "error": "Username already registered" }` | +| 409 | `{ "error": "Public key already registered to another username" }` | --- @@ -435,17 +507,18 @@ Fetch account info and balances from Horizon. Requires JWT; caller may only acce **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| --------- | ------ | ------------------------------------ | | publicKey | string | Stellar `G...` public key (56 chars) | **Headers** -| Name | Value | -|------|-------| +| Name | Value | +| ------------- | -------------- | | Authorization | `Bearer ` | **Response `200`** + ```json { "success": true, @@ -466,13 +539,13 @@ Fetch account info and balances from Horizon. Requires JWT; caller may only acce **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Invalid Stellar public key format" }` | -| 401 | `{ "error": "Unauthorized: missing or invalid token" }` | -| 401 | `{ "error": "Unauthorized: invalid or expired token" }` | -| 403 | `{ "error": "Forbidden: you may only access your own account data" }` | -| 404 | `{ "error": "Account not found. It may not be funded yet. Use Friendbot on testnet." }` | +| Status | Body | +| ------ | --------------------------------------------------------------------------------------- | +| 400 | `{ "error": "Invalid Stellar public key format" }` | +| 401 | `{ "error": "Unauthorized: missing or invalid token" }` | +| 401 | `{ "error": "Unauthorized: invalid or expired token" }` | +| 403 | `{ "error": "Forbidden: you may only access your own account data" }` | +| 404 | `{ "error": "Account not found. It may not be funded yet. Use Friendbot on testnet." }` | --- @@ -481,6 +554,7 @@ Fetch account info and balances from Horizon. Requires JWT; caller may only acce Fetch native XLM balance only. Same auth and rate-limit rules as `GET /api/accounts/:publicKey`. **Response `200`** + ```json { "success": true, @@ -503,18 +577,19 @@ Payment history from Horizon. Subject to **strict** rate limit. **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| --------- | ------ | ------------------------- | | publicKey | string | Stellar `G...` public key | **Query parameters** -| Name | Type | Default | Description | -|------|------|---------|-------------| -| limit | integer | 20 | Max results (capped at 100) | -| cursor | string | — | Horizon pagination cursor | +| Name | Type | Default | Description | +| ------ | ------- | ------- | --------------------------- | +| limit | integer | 20 | Max results (capped at 100) | +| cursor | string | — | Horizon pagination cursor | **Response `200`** + ```json { "success": true, @@ -537,9 +612,9 @@ Payment history from Horizon. Subject to **strict** rate limit. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Invalid Stellar public key format" }` | +| Status | Body | +| ------ | -------------------------------------------------- | +| 400 | `{ "error": "Invalid Stellar public key format" }` | --- @@ -549,11 +624,12 @@ Aggregate payment statistics (computed from up to 100 recent payments). **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| --------- | ------ | ------------------------- | | publicKey | string | Stellar `G...` public key | **Response `200`** + ```json { "success": true, @@ -570,9 +646,9 @@ Aggregate payment statistics (computed from up to 100 recent payments). **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Invalid Stellar public key format" }` | +| Status | Body | +| ------ | -------------------------------------------------- | +| 400 | `{ "error": "Invalid Stellar public key format" }` | --- @@ -584,11 +660,12 @@ All analytics routes use a 5-minute in-memory cache per public key. Subject to * **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| --------- | ------ | ------------------------- | | publicKey | string | Stellar `G...` public key | **Response `200`** + ```json { "success": true, @@ -610,6 +687,7 @@ All analytics routes use a 5-minute in-memory cache per public key. Subject to * Top 5 recipients by total XLM sent. **Response `200`** + ```json { "success": true, @@ -633,6 +711,7 @@ Top 5 recipients by total XLM sent. Payment counts grouped by day of week (UTC). **Response `200`** + ```json { "success": true, @@ -648,9 +727,9 @@ Payment counts grouped by day of week (UTC). **Errors (all analytics routes)** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Invalid Stellar public key format" }` | +| Status | Body | +| ------ | -------------------------------------------------- | +| 400 | `{ "error": "Invalid Stellar public key format" }` | --- @@ -664,16 +743,17 @@ Record a tip after an on-chain payment. **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| senderPublicKey | string | yes | Sender `G...` key | -| creatorPublicKey | string | yes | Creator `G...` key | -| amount | string | yes | Positive numeric amount | -| asset | string | no | Asset code (default `"XLM"`) | -| memo | string | no | Optional message | -| txHash | string | no | Stellar transaction hash | +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ---------------------------- | +| senderPublicKey | string | yes | Sender `G...` key | +| creatorPublicKey | string | yes | Creator `G...` key | +| amount | string | yes | Positive numeric amount | +| asset | string | no | Asset code (default `"XLM"`) | +| memo | string | no | Optional message | +| txHash | string | no | Stellar transaction hash | **Example request** + ```json { "senderPublicKey": "GABC1234567890123456789012345678901234567890123456789012345", @@ -686,6 +766,7 @@ Record a tip after an on-chain payment. ``` **Response `201`** + ```json { "success": true, @@ -705,11 +786,11 @@ Record a tip after an on-chain payment. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "senderPublicKey is required, creatorPublicKey is required, ..." }` (combined validation messages) | -| 400 | `{ "error": "Invalid sender public key format" }` | -| 400 | `{ "error": "amount must be a positive number" }` | +| Status | Body | +| ------ | -------------------------------------------------------------------------------------------------------------- | +| 400 | `{ "error": "senderPublicKey is required, creatorPublicKey is required, ..." }` (combined validation messages) | +| 400 | `{ "error": "Invalid sender public key format" }` | +| 400 | `{ "error": "amount must be a positive number" }` | --- @@ -719,18 +800,19 @@ Tips received by a creator, with embedded stats. **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| ---------------- | ------ | ------------------ | | creatorPublicKey | string | Creator `G...` key | **Query parameters** -| Name | Type | Default | Description | -|------|------|---------|-------------| -| limit | integer | 50 | Page size | -| offset | integer | 0 | Skip count | +| Name | Type | Default | Description | +| ------ | ------- | ------- | ----------- | +| limit | integer | 50 | Page size | +| offset | integer | 0 | Skip count | **Response `200`** + ```json { "success": true, @@ -770,6 +852,7 @@ Tips received by a creator, with embedded stats. Tip statistics for a creator. **Response `200`** + ```json { "success": true, @@ -793,12 +876,13 @@ Tips sent by a user. **Query parameters** -| Name | Type | Default | Description | -|------|------|---------|-------------| -| limit | integer | 50 | Page size | -| offset | integer | 0 | Skip count | +| Name | Type | Default | Description | +| ------ | ------- | ------- | ----------- | +| limit | integer | 50 | Page size | +| offset | integer | 0 | Skip count | **Response `200`** + ```json { "success": true, @@ -825,11 +909,12 @@ List deployments. **Query parameters** -| Name | Type | Required | Description | -|------|------|----------|-------------| -| ownerPublicKey | string | no | Filter by owner `G...` key | +| Name | Type | Required | Description | +| -------------- | ------ | -------- | -------------------------- | +| ownerPublicKey | string | no | Filter by owner `G...` key | **Response `200`** + ```json { "success": true, @@ -839,7 +924,12 @@ List deployments. "ownerPublicKey": "GABC...", "type": "dca", "status": "active", - "config": { "intervalMinutes": 60, "amountQuote": 10, "quoteAssetCode": "USDC", "quoteAssetIssuer": null }, + "config": { + "intervalMinutes": 60, + "amountQuote": 10, + "quoteAssetCode": "USDC", + "quoteAssetIssuer": null + }, "deploymentHash": "abc123...", "createdAt": "2025-01-01T12:00:00.000Z", "nextRunAt": "2025-01-01T13:00:00.000Z", @@ -854,9 +944,9 @@ List deployments. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Invalid Stellar public key format" }` | +| Status | Body | +| ------ | -------------------------------------------------- | +| 400 | `{ "error": "Invalid Stellar public key format" }` | --- @@ -866,13 +956,14 @@ Create a signing challenge (ManageData transaction XDR). **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| ownerPublicKey | string | yes | Owner `G...` key | -| type | string | yes | `dca`, `stop_loss`, or `escrow_release` | -| config | object | yes | Type-specific configuration | +| Field | Type | Required | Description | +| -------------- | ------ | -------- | --------------------------------------- | +| ownerPublicKey | string | yes | Owner `G...` key | +| type | string | yes | `dca`, `stop_loss`, or `escrow_release` | +| config | object | yes | Type-specific configuration | **Example request (DCA)** + ```json { "ownerPublicKey": "GABC1234567890123456789012345678901234567890123456789012345", @@ -887,6 +978,7 @@ Create a signing challenge (ManageData transaction XDR). ``` **Response `200`** + ```json { "success": true, @@ -906,11 +998,11 @@ Create a signing challenge (ManageData transaction XDR). **Errors** -| Status | Body (examples) | -|--------|-------------------| -| 400 | `{ "error": "Invalid Stellar public key format" }` | -| 400 | `{ "error": "Unsupported txFunction type. Use 'dca', 'stop_loss', or 'escrow_release'." }` | -| 400 | `{ "error": "DCA intervalMinutes must be at least 1" }` | +| Status | Body (examples) | +| ------ | ------------------------------------------------------------------------------------------ | +| 400 | `{ "error": "Invalid Stellar public key format" }` | +| 400 | `{ "error": "Unsupported txFunction type. Use 'dca', 'stop_loss', or 'escrow_release'." }` | +| 400 | `{ "error": "DCA intervalMinutes must be at least 1" }` | --- @@ -920,15 +1012,16 @@ Deploy a txFunction after signing the challenge. **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| ownerPublicKey | string | yes | Owner `G...` key | -| type | string | yes | `dca`, `stop_loss`, or `escrow_release` | -| config | object | yes | Same config used for challenge | -| deploymentHash | string | yes | Hash from challenge response | -| signedChallengeXDR | string | yes | Challenge XDR signed by owner | +| Field | Type | Required | Description | +| ------------------ | ------ | -------- | --------------------------------------- | +| ownerPublicKey | string | yes | Owner `G...` key | +| type | string | yes | `dca`, `stop_loss`, or `escrow_release` | +| config | object | yes | Same config used for challenge | +| deploymentHash | string | yes | Hash from challenge response | +| signedChallengeXDR | string | yes | Challenge XDR signed by owner | **Response `201`** + ```json { "success": true, @@ -937,7 +1030,7 @@ Deploy a txFunction after signing the challenge. "ownerPublicKey": "GABC...", "type": "dca", "status": "active", - "config": { }, + "config": {}, "deploymentHash": "a1b2c3...", "createdAt": "2025-01-01T12:00:00.000Z", "nextRunAt": "2025-01-01T13:00:00.000Z" @@ -947,11 +1040,11 @@ Deploy a txFunction after signing the challenge. **Errors** -| Status | Body (examples) | -|--------|-------------------| -| 400 | `{ "error": "Configuration hash mismatch. Recreate challenge and sign again." }` | -| 400 | `{ "error": "Asset issuer is required for non-native asset USDC" }` | -| 401 | `{ "error": "Signed challenge was not signed by the owner account" }` | +| Status | Body (examples) | +| ------ | -------------------------------------------------------------------------------- | +| 400 | `{ "error": "Configuration hash mismatch. Recreate challenge and sign again." }` | +| 400 | `{ "error": "Asset issuer is required for non-native asset USDC" }` | +| 401 | `{ "error": "Signed challenge was not signed by the owner account" }` | --- @@ -961,17 +1054,17 @@ Get a single deployment. **Path parameters** -| Name | Type | Description | -|------|------|-------------| -| id | string (UUID) | Deployment ID | +| Name | Type | Description | +| ---- | ------------- | ------------- | +| id | string (UUID) | Deployment ID | **Response `200`** — `{ "success": true, "data": { ...deployment } }` **Errors** -| Status | Body | -|--------|------| -| 404 | `{ "error": "txFunction not found" }` | +| Status | Body | +| ------ | ------------------------------------- | +| 404 | `{ "error": "txFunction not found" }` | --- @@ -980,6 +1073,7 @@ Get a single deployment. Execution log for a deployment (newest first). **Response `200`** + ```json { "success": true, @@ -998,9 +1092,9 @@ Execution log for a deployment (newest first). **Errors** -| Status | Body | -|--------|------| -| 404 | `{ "error": "txFunction not found" }` | +| Status | Body | +| ------ | ------------------------------------- | +| 404 | `{ "error": "txFunction not found" }` | --- @@ -1028,13 +1122,14 @@ Register Horizon SSE listeners that POST to your URL when payments are received. **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| publicKey | string | yes | Account to monitor | -| url | string | yes | HTTPS endpoint to receive events | -| secret | string | yes | HMAC secret for `X-Webhook-Signature` | +| Field | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------- | +| publicKey | string | yes | Account to monitor | +| url | string | yes | HTTPS endpoint to receive events | +| secret | string | yes | HMAC secret for `X-Webhook-Signature` | **Example request** + ```json { "publicKey": "GABC1234567890123456789012345678901234567890123456789012345", @@ -1044,6 +1139,7 @@ Register Horizon SSE listeners that POST to your URL when payments are received. ``` **Response `201`** + ```json { "success": true, @@ -1059,12 +1155,13 @@ Register Horizon SSE listeners that POST to your URL when payments are received. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "publicKey, url, and secret are required" }` | -| 500 | `{ "error": "" }` | +| Status | Body | +| ------ | -------------------------------------------------------- | +| 400 | `{ "error": "publicKey, url, and secret are required" }` | +| 500 | `{ "error": "" }` | **Outbound webhook payload** (POST to your `url`) + ```json { "event": "payment.received", @@ -1089,6 +1186,7 @@ Header: `X-Webhook-Signature` — HMAC-SHA256 hex of the JSON body using `secret List webhooks for an account. **Response `200`** + ```json { "webhooks": [ @@ -1111,11 +1209,12 @@ Delete a webhook by numeric ID. **Path parameters** -| Name | Type | Description | -|------|------|-------------| -| id | string | Webhook ID assigned at registration | +| Name | Type | Description | +| ---- | ------ | ----------------------------------- | +| id | string | Webhook ID assigned at registration | **Response `200`** + ```json { "success": true, @@ -1125,9 +1224,9 @@ Delete a webhook by numeric ID. **Errors** -| Status | Body | -|--------|------| -| 404 | `{ "error": "Webhook not found" }` | +| Status | Body | +| ------ | ---------------------------------- | +| 404 | `{ "error": "Webhook not found" }` | --- @@ -1143,11 +1242,12 @@ Parse a natural language payment description into a structured payment intent. **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| input | string | yes | Natural language payment description | +| Field | Type | Required | Description | +| ----- | ------ | -------- | ------------------------------------ | +| input | string | yes | Natural language payment description | **Example request** + ```json { "input": "Send 50 XLM to GABC123 for design work" @@ -1155,6 +1255,7 @@ Parse a natural language payment description into a structured payment intent. ``` **Response `200`** + ```json { "amount": "50 XLM", @@ -1166,6 +1267,7 @@ Parse a natural language payment description into a structured payment intent. ``` **Response `200` (ambiguous input)** + ```json { "amount": "", @@ -1178,11 +1280,11 @@ Parse a natural language payment description into a structured payment intent. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "amount": "", "recipient": "", "memo": "", "isValid": false, "clarification": "Please provide a payment description." }` | -| 501 | `{ "amount": "", "recipient": "", "memo": "", "isValid": false, "clarification": "AI payment parsing is not configured. Set ANTHROPIC_API_KEY." }` | -| 500 | `{ "amount": "", "recipient": "", "memo": "", "isValid": false, "clarification": "Server error. Try again." }` | +| Status | Body | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| 400 | `{ "amount": "", "recipient": "", "memo": "", "isValid": false, "clarification": "Please provide a payment description." }` | +| 501 | `{ "amount": "", "recipient": "", "memo": "", "isValid": false, "clarification": "AI payment parsing is not configured. Set ANTHROPIC_API_KEY." }` | +| 500 | `{ "amount": "", "recipient": "", "memo": "", "isValid": false, "clarification": "Server error. Try again." }` | --- @@ -1196,13 +1298,14 @@ Schedule a transaction for future submission. **Request body (JSON)** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| signedXDR | string | yes | Signed transaction XDR (base64) | -| submitAt | string | yes | ISO 8601 timestamp when the transaction should be submitted | -| publicKey | string | yes | Stellar public key that owns this transaction | +| Field | Type | Required | Description | +| --------- | ------ | -------- | ----------------------------------------------------------- | +| signedXDR | string | yes | Signed transaction XDR (base64) | +| submitAt | string | yes | ISO 8601 timestamp when the transaction should be submitted | +| publicKey | string | yes | Stellar public key that owns this transaction | **Example request** + ```json { "signedXDR": "AAAAAgAAAAC...", @@ -1212,6 +1315,7 @@ Schedule a transaction for future submission. ``` **Response `201`** + ```json { "message": "Transaction scheduled successfully", @@ -1223,10 +1327,10 @@ Schedule a transaction for future submission. **Errors** -| Status | Body | -|--------|------| -| 400 | `{ "error": "Missing signedXDR, submitAt, or publicKey" }` | -| 400 | `{ "error": "submitAt must be a valid ISO 8601 date string" }` | +| Status | Body | +| ------ | -------------------------------------------------------------- | +| 400 | `{ "error": "Missing signedXDR, submitAt, or publicKey" }` | +| 400 | `{ "error": "submitAt must be a valid ISO 8601 date string" }` | --- @@ -1236,11 +1340,12 @@ List all pending scheduled transactions for a public key (sorted by earliest fir **Path parameters** -| Name | Type | Description | -|------|------|-------------| +| Name | Type | Description | +| --------- | ------ | ------------------------- | | publicKey | string | Stellar `G...` public key | **Response `200`** + ```json [ { @@ -1261,11 +1366,12 @@ Cancel a scheduled transaction by its ID. **Path parameters** -| Name | Type | Description | -|------|------|-------------| -| id | string | Transaction ID assigned at scheduling | +| Name | Type | Description | +| ---- | ------ | ------------------------------------- | +| id | string | Transaction ID assigned at scheduling | **Response `200`** + ```json { "message": "Transaction 1 cancelled successfully." @@ -1274,9 +1380,9 @@ Cancel a scheduled transaction by its ID. **Errors** -| Status | Body | -|--------|------| -| 404 | `{ "error": "Transaction 1 not found or not pending." }` | +| Status | Body | +| ------ | -------------------------------------------------------- | +| 404 | `{ "error": "Transaction 1 not found or not pending." }` | --- @@ -1284,17 +1390,18 @@ Cancel a scheduled transaction by its ID. All errors now follow the standardized shape (see [Error Codes Reference](#error-codes-reference)). -| HTTP status | Error Code | When | -|-------------|------------|------| -| 400 | `VAL_INVALID_JSON` | Invalid JSON body | -| 404 | `RES_ROUTE_NOT_FOUND` | Unknown route | -| 415 | `VAL_CONTENT_TYPE` | Missing `Content-Type: application/json` | -| 413 | `VAL_BODY_TOO_LARGE` | Request body exceeds size limit | -| 429 | `RATE_LIMITED_GLOBAL` | Global rate limit exceeded | -| 429 | `RATE_LIMITED_SENSITIVE` | Strict rate limit exceeded | -| 500 | `SRV_INTERNAL` | Unhandled server error | +| HTTP status | Error Code | When | +| ----------- | ------------------------ | ---------------------------------------- | +| 400 | `VAL_INVALID_JSON` | Invalid JSON body | +| 404 | `RES_ROUTE_NOT_FOUND` | Unknown route | +| 415 | `VAL_CONTENT_TYPE` | Missing `Content-Type: application/json` | +| 413 | `VAL_BODY_TOO_LARGE` | Request body exceeds size limit | +| 429 | `RATE_LIMITED_GLOBAL` | Global rate limit exceeded | +| 429 | `RATE_LIMITED_SENSITIVE` | Strict rate limit exceeded | +| 500 | `SRV_INTERNAL` | Unhandled server error | **Example standardized error response:** + ```json { "error": { @@ -1312,10 +1419,10 @@ All errors now follow the standardized shape (see [Error Codes Reference](#error When `TURRETS_PORT` is set (default `4100`), a separate process exposes: -| Method | Path | Description | -|--------|------|-------------| -| GET | `http://localhost:4100/health` | Sidecar health | -| * | `http://localhost:4100/tx-functions/*` | Same txFunction routes as `/api/turrets/*` on the main server | +| Method | Path | Description | +| ------ | -------------------------------------- | ------------------------------------------------------------- | +| GET | `http://localhost:4100/health` | Sidecar health | +| * | `http://localhost:4100/tx-functions/*` | Same txFunction routes as `/api/turrets/*` on the main server | The main API on port **4000** mounts turrets at `/api/turrets`; prefer that URL for application integration.