Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 60 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo-url>
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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export function buildOpenApiSpec(): Record<string, unknown> {
"/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" },
},
Expand Down
126 changes: 126 additions & 0 deletions src/routes/liquidity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
6 changes: 6 additions & 0 deletions src/routes/liquidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down
Loading
Loading