diff --git a/README.md b/README.md index 88739f3..bdb00bc 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,24 @@ npm (or yarn/pnpm) Setup Bash -# Clone the repo (or use your fork) +Clone the repo (or use your fork) git clone cd anchornet-backend -# Install dependencies +Install dependencies npm install -# Run in development +Run in development npm run dev Server runs at http://localhost:3001 by default. Set PORT to override. Scripts -Command Description -npm run dev Start dev server with hot reload -npm run build Compile TypeScript to dist/ -npm start Run production build -npm test Run tests (Jest) -npm run lint Run ESLint +Command Description +npm run dev Start dev server with hot reload +npm run build Compile TypeScript to dist/ +npm start Run production build +npm test Run tests (Jest) +npm run lint Run ESLint API Service GET /health – health check @@ -47,6 +47,15 @@ previously recorded by an anchor, mirroring the on-chain contract's withdraw_liquidity. Reduces the anchor's balance and removes the entry once it reaches zero. Returns 404 if the anchor holds no balance in the asset, or 400 (INSUFFICIENT_LIQUIDITY) if the amount exceeds it. +POST /api/v1/liquidity/transfer – atomically move liquidity +{ from, to, asset, amount } between two anchors for the same asset. +Decrements the source anchor and increments the destination anchor in a +single operation, so the pool total never dips mid-move (unlike a +withdraw followed by a separate add). Returns the updated entries for +both anchors as { from, to }. Returns 404 if the source anchor holds no +balance in the asset, 400 (INSUFFICIENT_LIQUIDITY) if the amount exceeds +the source balance — in which case neither balance changes — and 400 if +from and to are the same anchor. GET /api/v1/liquidity – list aggregated pools { pools: [{ asset, total, anchors }] } GET /api/v1/liquidity/entries – list raw per-anchor entries GET /api/v1/liquidity/:asset – aggregated pool for one asset (404 if none) @@ -128,10 +137,10 @@ Initial request: Send a POST request to register an anchor with a unique Idempot Bash -curl -i -X POST http://localhost:3001/api/v1/anchors \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: register-anchor-xyz" \ - -d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' +curl -i -X POST http://localhost:3001/api/v1/anchors +-H "Content-Type: application/json" +-H "Idempotency-Key: register-anchor-xyz" +-d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' Response: http @@ -141,19 +150,19 @@ Content-Type: application/json; charset=utf-8 x-request-id: df743737-896c-4e4f-8dae-1c08a95302cd { - "id": "anchor-xyz", - "name": "Anchor XYZ", - "registeredAt": "2026-07-22T14:17:57.537Z", - "active": true +"id": "anchor-xyz", +"name": "Anchor XYZ", +"registeredAt": "2026-07-22T14:17:57.537Z", +"active": true } Subsequent replay: Send the exact same request again using the same Idempotency-Key. The server returns the cached 201 response immediately, bypassing the normal handler and avoiding a 409 (which would normally happen for duplicate anchor registration): Bash -curl -i -X POST http://localhost:3001/api/v1/anchors \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: register-anchor-xyz" \ - -d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' +curl -i -X POST http://localhost:3001/api/v1/anchors +-H "Content-Type: application/json" +-H "Idempotency-Key: register-anchor-xyz" +-d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' Response (Cached): http @@ -163,19 +172,19 @@ Content-Type: application/json; charset=utf-8 x-request-id: 4a123f52-1623-429b-ba67-3d0d0d5c2eb0 { - "id": "anchor-xyz", - "name": "Anchor XYZ", - "registeredAt": "2026-07-22T14:17:57.537Z", - "active": true +"id": "anchor-xyz", +"name": "Anchor XYZ", +"registeredAt": "2026-07-22T14:17:57.537Z", +"active": true } Mismatched body (Known Gap): If you reuse the same Idempotency-Key but change the request payload (e.g., modifying the name field), the server will still return the cached 201 response corresponding to the first payload. Detecting mismatched request bodies (which would ideally return a 422 error) is currently a known gap in this system. Bash -curl -i -X POST http://localhost:3001/api/v1/anchors \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: register-anchor-xyz" \ - -d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}' +curl -i -X POST http://localhost:3001/api/v1/anchors +-H "Content-Type: application/json" +-H "Idempotency-Key: register-anchor-xyz" +-d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}' Response (Replayed from the original cached version): http @@ -185,10 +194,10 @@ Content-Type: application/json; charset=utf-8 x-request-id: 184c8357-3fc3-4e2f-a87c-19042ab804fe { - "id": "anchor-xyz", - "name": "Anchor XYZ", - "registeredAt": "2026-07-22T14:17:57.537Z", - "active": true +"id": "anchor-xyz", +"name": "Anchor XYZ", +"registeredAt": "2026-07-22T14:17:57.537Z", +"active": true } The process shuts down gracefully on SIGTERM/SIGINT: it stops accepting new connections, closes the HTTP server, marks /health/ready unready, and @@ -204,27 +213,27 @@ operators can pause writes without taking the whole API down. Configuration The application is configured using environment variables. Every environment variable read by config.ts is listed below with its default and valid range/format: -Variable Default Valid Range / Format Description -PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default. -FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range. -API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. -CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted. -BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank. -MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally. -NODE_ENV development Any environment name string (e.g., "development", "production", "test") Specifies the runtime environment name. -METRICS_SNAPSHOT_INTERVAL_MS (Unset) Positive integer Optional interval in milliseconds to automatically take metrics snapshots. -IDEMPOTENCY_TTL_MS 86400000 (24h) Positive integer Milliseconds that a cached response remains eligible for idempotency replay. -RATE_LIMIT_MAX 30 Positive integer Maximum mutating requests allowed per client within the rolling rate-limiting window. -RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling rate-limiting window, in milliseconds. +Variable Default Valid Range / Format Description +PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default. +FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range. +API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. +CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted. +BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank. +MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally. +NODE_ENV development Any environment name string (e.g., "development", "production", "test") Specifies the runtime environment name. +METRICS_SNAPSHOT_INTERVAL_MS (Unset) Positive integer Optional interval in milliseconds to automatically take metrics snapshots. +IDEMPOTENCY_TTL_MS 86400000 (24h) Positive integer Milliseconds that a cached response remains eligible for idempotency replay. +RATE_LIMIT_MAX 30 Positive integer Maximum mutating requests allowed per client within the rolling rate-limiting window. +RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling rate-limiting window, in milliseconds. Architecture text -routes/ HTTP layer (thin controllers) -services/ business rules (liquidity, quotes, anchors, settlements) -repositories/ in-memory stores (swappable for an indexer) -middleware/ request id, logging, API-key auth, rate limiting, error handling -models/ domain types -config.ts env-based configuration +routes/ HTTP layer (thin controllers) +services/ business rules (liquidity, quotes, anchors, settlements) +repositories/ in-memory stores (swappable for an indexer) +middleware/ request id, logging, API-key auth, rate limiting, error handling +models/ domain types +config.ts env-based configuration Contributing Fork the repo and create a branch from main. Install deps: npm install. Run tests: npm test; lint: npm run lint. diff --git a/src/openapi.ts b/src/openapi.ts index 436c469..4de59b4 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -51,6 +51,16 @@ export function buildOpenApiSpec(): Record { "/api/v1/liquidity/withdraw": { post: { summary: "Withdraw previously recorded liquidity" }, }, + "/api/v1/liquidity/transfer": { + post: { + summary: + "Atomically transfer liquidity between two anchors for the same asset. " + + "Decrements the source anchor and increments the destination anchor in a " + + "single operation, so the pool total never changes mid-transfer. Returns " + + "400 (INSUFFICIENT_LIQUIDITY) without changing any balance when the source " + + "anchor cannot cover the amount.", + }, + }, "/api/v1/liquidity/entries": { get: { summary: "List raw per-anchor liquidity entries" }, }, diff --git a/src/routes/liquidity.test.ts b/src/routes/liquidity.test.ts index 9e8f775..ee4f39b 100644 --- a/src/routes/liquidity.test.ts +++ b/src/routes/liquidity.test.ts @@ -290,4 +290,130 @@ describe("liquidity routes", () => { expect(Array.isArray(res.body.withdrawals)).toBe(true); expect(res.body.withdrawals).toEqual([]); }); + it("transfers liquidity between two anchors in a single atomic operation", async () => { + const app = createApp(); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + + const res = await request(app) + .post("/api/v1/liquidity/transfer") + .send({ from: "anchorA", to: "anchorB", asset: "usdc", amount: 200 }); + + expect(res.status).toBe(200); + expect(res.body.from).toMatchObject({ + anchor: "anchorA", + asset: "USDC", + amount: 300, + }); + expect(res.body.to).toMatchObject({ + anchor: "anchorB", + asset: "USDC", + amount: 500, + }); + + // The pool total is unchanged by the transfer. + const pool = await request(app).get("/api/v1/liquidity/USDC"); + expect(pool.body.total).toBe(800); + expect(pool.body.anchors).toBe(2); + }); + + it("creates the destination entry when transferring to an anchor with no balance", async () => { + const app = createApp(); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + + const res = await request(app) + .post("/api/v1/liquidity/transfer") + .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: 500 }); + + expect(res.status).toBe(200); + expect(res.body.from.amount).toBe(0); + expect(res.body.to.amount).toBe(500); + + const entries = await request(app).get("/api/v1/liquidity/entries"); + expect(entries.body.entries).toHaveLength(1); + expect(entries.body.entries[0]).toMatchObject({ + anchor: "anchorB", + amount: 500, + }); + }); + + it("returns 400 INSUFFICIENT_LIQUIDITY and leaves both balances unchanged", async () => { + const app = createApp(); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + + const res = await request(app) + .post("/api/v1/liquidity/transfer") + .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: 200 }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("INSUFFICIENT_LIQUIDITY"); + + // Atomicity: the failed transfer changed nothing on either side. + const entries = await request(app).get("/api/v1/liquidity/entries"); + const byAnchor = Object.fromEntries( + entries.body.entries.map((e: any) => [e.anchor, e.amount]), + ); + expect(byAnchor).toEqual({ anchorA: 100, anchorB: 300 }); + + const pool = await request(app).get("/api/v1/liquidity/USDC"); + expect(pool.body.total).toBe(400); + }); + + it("returns 404 when transferring from an anchor with no balance", async () => { + const app = createApp(); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + + const res = await request(app) + .post("/api/v1/liquidity/transfer") + .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: 10 }); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("NOT_FOUND"); + + const entries = await request(app).get("/api/v1/liquidity/entries"); + expect(entries.body.entries).toHaveLength(1); + expect(entries.body.entries[0]).toMatchObject({ + anchor: "anchorB", + amount: 300, + }); + }); + + it("returns 400 for invalid transfer input", async () => { + const app = createApp(); + const res = await request(app) + .post("/api/v1/liquidity/transfer") + .send({ from: "anchorA", to: "", asset: "USDC", amount: -5 }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("BAD_REQUEST"); + }); + + it("returns 400 when transferring an anchor's liquidity to itself", async () => { + const app = createApp(); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + + const res = await request(app) + .post("/api/v1/liquidity/transfer") + .send({ from: "anchorA", to: "anchorA", asset: "USDC", amount: 50 }); + + expect(res.status).toBe(400); + + const pool = await request(app).get("/api/v1/liquidity/USDC"); + expect(pool.body.total).toBe(100); + }); }); diff --git a/src/routes/liquidity.ts b/src/routes/liquidity.ts index 5c65f65..1059884 100644 --- a/src/routes/liquidity.ts +++ b/src/routes/liquidity.ts @@ -20,6 +20,12 @@ export function liquidityRouter(service: LiquidityService): Router { res.json(entry); }); + // Atomically transfer liquidity between two anchors for the same asset. + router.post("/transfer", (req: Request, res: Response) => { + const result = service.transferLiquidity(req.body ?? {}); + res.json(result); + }); + // List aggregated pools across all assets. router.get("/", (_req: Request, res: Response) => { res.json({ pools: service.listPools() }); diff --git a/src/services/liquidityService.test.ts b/src/services/liquidityService.test.ts index 5773070..c3b5a3f 100644 --- a/src/services/liquidityService.test.ts +++ b/src/services/liquidityService.test.ts @@ -149,6 +149,172 @@ describe("LiquidityService", () => { expect(entriesB).toHaveLength(1); expect(entriesB[0].asset).toBe("USDC"); }); + + describe("transferLiquidity", () => { + it("moves liquidity between two anchors atomically in one operation", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); + + const result = service.transferLiquidity({ + from: "anchorA", + to: "anchorB", + asset: "usdc", + amount: 40, + }); + + expect(result.from).toMatchObject({ + anchor: "anchorA", + asset: "USDC", + amount: 60, + }); + expect(result.to).toMatchObject({ + anchor: "anchorB", + asset: "USDC", + amount: 90, + }); + // The pool total is unchanged: the transfer never reduced it. + expect(service.getPool("USDC").total).toBe(150); + }); + + it("creates the destination entry when the target anchor has none", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + + const result = service.transferLiquidity({ + from: "anchorA", + to: "anchorB", + asset: "USDC", + amount: 25, + }); + + expect(result.to.amount).toBe(25); + expect(service.listByAnchor("anchorB")).toHaveLength(1); + expect(service.getPool("USDC").total).toBe(100); + }); + + it("removes the source entry once its full balance is transferred", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 10 }); + + const result = service.transferLiquidity({ + from: "anchorA", + to: "anchorB", + asset: "USDC", + amount: 100, + }); + + expect(result.from.amount).toBe(0); + expect(result.to.amount).toBe(110); + expect(service.listByAnchor("anchorA")).toHaveLength(0); + expect(service.getPool("USDC").total).toBe(110); + }); + + it("leaves both anchors' balances unchanged when the source is insufficient", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); + + expect(() => + service.transferLiquidity({ + from: "anchorA", + to: "anchorB", + asset: "USDC", + amount: 150, + }), + ).toThrow( + expect.objectContaining({ + status: 400, + code: "INSUFFICIENT_LIQUIDITY", + }), + ); + + // Atomicity: neither side moved and the pool total is intact. + expect(service.listByAnchor("anchorA")[0].amount).toBe(100); + expect(service.listByAnchor("anchorB")[0].amount).toBe(50); + expect(service.getPool("USDC").total).toBe(150); + }); + + it("throws 404 without creating a destination entry when the source has no balance", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); + + expect(() => + service.transferLiquidity({ + from: "anchorA", + to: "anchorB", + asset: "USDC", + amount: 10, + }), + ).toThrow(expect.objectContaining({ status: 404, code: "NOT_FOUND" })); + + expect(service.listByAnchor("anchorA")).toHaveLength(0); + expect(service.listByAnchor("anchorB")[0].amount).toBe(50); + expect(service.getPool("USDC").total).toBe(50); + }); + + it("rejects a transfer to the same anchor without changing its balance", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + + expect(() => + service.transferLiquidity({ + from: "anchorA", + to: "anchorA", + asset: "USDC", + amount: 50, + }), + ).toThrow(ApiError); + + expect(service.listByAnchor("anchorA")[0].amount).toBe(100); + expect(service.getPool("USDC").total).toBe(100); + }); + + it("rejects invalid inputs without changing any balance", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + + const badInputs = [ + { from: " ", to: "anchorB", asset: "USDC", amount: 10 }, + { from: "anchorA", to: "", asset: "USDC", amount: 10 }, + { from: "anchorA", to: "anchorB", asset: "USDC", amount: -5 }, + { from: "anchorA", to: "anchorB", asset: "USDC", amount: 0 }, + { + from: "anchorA", + to: "anchorB", + asset: "TOOLONGASSETCODE", + amount: 10, + }, + ]; + for (const input of badInputs) { + expect(() => service.transferLiquidity(input)).toThrow(ApiError); + } + + expect(service.listByAnchor("anchorA")[0].amount).toBe(100); + expect(service.listByAnchor("anchorB")).toHaveLength(0); + expect(service.getPool("USDC").total).toBe(100); + }); + + it("does not touch balances in other assets", () => { + const service = makeService(); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "EURC", amount: 75 }); + + service.transferLiquidity({ + from: "anchorA", + to: "anchorB", + asset: "USDC", + amount: 40, + }); + + const anchorAEurc = service + .listByAnchor("anchorA") + .find((e) => e.asset === "EURC"); + expect(anchorAEurc?.amount).toBe(75); + expect(service.getPool("EURC").total).toBe(75); + }); + }); }); describe("LiquidityService withdrawal history", () => { diff --git a/src/services/liquidityService.ts b/src/services/liquidityService.ts index 68c4df6..69215a0 100644 --- a/src/services/liquidityService.ts +++ b/src/services/liquidityService.ts @@ -122,6 +122,86 @@ export class LiquidityService { return this.repo.upsert({ anchor, asset, amount: remaining, updatedAt }); } + /** + * Transfers `amount` of liquidity in `asset` from one anchor to another, + * atomically, as a single logical operation. + * + * This replaces the withdraw-then-add two-step, which was not atomic and + * briefly reduced the pool total between the two calls. All validation runs + * before any mutation, so a rejected transfer never changes either anchor's + * balance. Throws 404 if the source anchor holds no balance in the asset, + * or 400 (`INSUFFICIENT_LIQUIDITY`) if the transfer exceeds the source + * balance, mirroring {@link withdrawLiquidity}. Self-transfers are rejected + * with 400. + * + * No reserved-liquidity check is needed: the source decrement always equals + * the destination increment, so the asset's pool total — and therefore the + * liquidity available for settlements — is unchanged by construction. + * + * Returns the resulting entries for both anchors. When the full source + * balance is transferred, the source entry is removed and returned with + * `amount: 0`, mirroring {@link withdrawLiquidity}. + */ + transferLiquidity(input: { + from: unknown; + to: unknown; + asset: unknown; + amount: unknown; + }): { from: LiquidityEntry; to: LiquidityEntry } { + const from = requireString(input.from, "from"); + const to = requireString(input.to, "to"); + const asset = normalizeAsset(input.asset); + const amount = requirePositiveNumber(input.amount, "amount"); + + if (from === to) { + throw ApiError.badRequest( + `"from" and "to" must be different anchors`, + "SAME_ANCHOR", + ); + } + + const source = this.repo.get(from, asset); + if (!source) { + throw ApiError.notFound( + `no liquidity balance for anchor "${from}" in ${asset}`, + ); + } + if (source.amount < amount) { + throw ApiError.badRequest( + `insufficient balance for ${asset}: requested ${amount}, available ${source.amount}`, + "INSUFFICIENT_LIQUIDITY", + ); + } + + // Every check that can throw is above this line, so the two mutations + // below are atomic in effect: the transfer is never partially applied. + const updatedAt = new Date().toISOString(); + const fromRemaining = source.amount - amount; + const destination = this.repo.get(to, asset); + const toTotal = (destination?.amount ?? 0) + amount; + + let fromEntry: LiquidityEntry; + if (fromRemaining === 0) { + this.repo.remove(from, asset); + fromEntry = { anchor: from, asset, amount: 0, updatedAt }; + } else { + fromEntry = this.repo.upsert({ + anchor: from, + asset, + amount: fromRemaining, + updatedAt, + }); + } + const toEntry = this.repo.upsert({ + anchor: to, + asset, + amount: toTotal, + updatedAt, + }); + + return { from: fromEntry, to: toEntry }; + } + /** * Removes an anchor's entire liquidity entry for an asset, regardless of * its current balance. Returns the removed entry, or 404 if none exists.