Skip to content
Closed
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
39 changes: 38 additions & 1 deletion apps/backend/payments/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @delego/payments

Delego **payments** service.
Delego **payments** service — coordinates escrow contract interactions on the Stellar/Soroban network.

## Development

Expand All @@ -9,3 +9,40 @@ pnpm --filter @delego/payments dev
```

Health check: `GET http://localhost:3014/health`

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `PAYMENTS_PORT` | `3014` | HTTP port the service listens on |
| `WALLET_URL` | `http://localhost:3012` | Base URL of the wallet service used to submit contract calls |
| `ESCROW_CONTRACT_ID` | _(required)_ | Soroban contract address of the deployed escrow contract |
| `REDIS_URL` | — | Redis connection URL (e.g. `redis://localhost:6379`). Takes priority over `REDIS_HOST`/`REDIS_PORT` |
| `REDIS_HOST` | `localhost` | Redis host (used when `REDIS_URL` is not set) |
| `REDIS_PORT` | `6379` | Redis port (used when `REDIS_URL` is not set) |
| `ESCROW_FUNDING_LOCK_TTL_MS` | `30000` | TTL in milliseconds for the distributed escrow funding lock. The lock auto-expires if the process crashes before releasing it. |
| `LOG_LEVEL` | `info` | Logging verbosity (`debug`, `info`, `warn`, `error`) |

## Escrow Funding Lock

Concurrent deposit requests for the **same `orderId`** are prevented with a Redis
distributed lock (`SET NX PX`). A Lua script ensures the lock can only be released
by the holder (token ownership check).

- **Conflict (409)**: A second deposit request for the same order while one is already
in flight returns `{ error: { code: "ESCROW_FUNDING_CONFLICT" } }`.
- **Lock unavailable (503)**: If Redis is unreachable when acquiring the lock the service
returns `{ error: { code: "LOCK_SERVICE_UNAVAILABLE" } }`. The client should retry.
- **Auto-expiry**: The lock has a configurable TTL (`ESCROW_FUNDING_LOCK_TTL_MS`) so a
crash or a missed release cannot permanently block an order.
- **Defense in depth**: The Soroban escrow contract enforces on-chain uniqueness as an
additional guard independent of the application-layer lock.

## API Routes

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/escrow/initialize` | Initialize the escrow contract on-chain |
| `POST` | `/escrow/deposit` | Fund an escrow (requires `Idempotency-Key` header; locks by `orderId` when provided) |
| `POST` | `/escrow/:escrowId/release` | Release escrowed funds to the seller (requires `Idempotency-Key`) |
| `POST` | `/escrow/:escrowId/refund` | Refund escrowed funds to the buyer (requires `Idempotency-Key`) |
6 changes: 4 additions & 2 deletions apps/backend/payments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
},
"dependencies": {
"@delego/types": "workspace:*",
"@delego/utils": "workspace:*"
"@delego/utils": "workspace:*",
"ioredis": "^5.3.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"@types/node": "^22.0.0"
"@types/node": "^22.0.0",
"ioredis-mock": "^8.9.0"
}
}
126 changes: 126 additions & 0 deletions apps/backend/payments/src/lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { randomUUID } from "node:crypto";
import { Redis } from "ioredis";
// @ts-ignore — ioredis-mock is a dev dependency used only in test/mock mode
import MockRedis from "ioredis-mock";
Comment on lines +2 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Files\n'
git ls-files 'apps/backend/payments/src/lock.ts' 'apps/backend/payments/src/routes.ts' 'apps/backend/payments/package.json'

printf '\n## lock.ts\n'
cat -n apps/backend/payments/src/lock.ts

printf '\n## routes.ts references to lock\n'
rg -n --context 3 'from .*/lock|from "./lock"|from "\./lock"|lock\.ts|lock' apps/backend/payments/src/routes.ts apps/backend/payments/src -g '!**/node_modules/**'

printf '\n## package.json\n'
cat -n apps/backend/payments/package.json

printf '\n## dependency declarations for ioredis-mock\n'
rg -n '"ioredis-mock"|ioredis-mock' apps/backend/payments/package.json apps/backend/payments -g '!**/node_modules/**'

Repository: DelegoLabs/Delego

Length of output: 18306


Avoid a top-level import of ioredis-mock. routes.ts loads this module in production, so Node resolves the mock package at startup even when useMock is false. With ioredis-mock only in devDependencies, a pruned production install will fail before serving requests. Lazy-load it inside the mock branch or move it to runtime dependencies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/payments/src/lock.ts` around lines 2 - 4, Avoid the top-level
import of ioredis-mock in the lock module, since routes.ts can load it in
production and trigger resolution of a dev-only package before useMock is
checked. Update the lock-related setup so MockRedis is loaded lazily only inside
the mock branch (or otherwise made available at runtime), keeping Redis usage
for the real path unchanged and referencing the existing lock.ts imports and
mock-creation path to locate the fix.

import { createLogger } from "@delego/utils";
import type { EscrowFundingLock } from "./validation.js";

export type { EscrowFundingLock };

const log = createLogger("payments:lock", process.env.LOG_LEVEL ?? "info");

const LOCK_KEY_PREFIX = "escrow:funding:lock:";
const DEFAULT_LOCK_TTL_MS = 30_000;

// Atomic check-and-delete: only removes the key when the stored token matches.
const RELEASE_SCRIPT = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;

export class LockServiceError extends Error {
constructor(message: string) {
super(message);
this.name = "LockServiceError";
}
}

let _client: Redis | null = null;

export function getLockRedisClient(): Redis {
if (!_client) {
const useMock =
process.env.NODE_ENV === "test" || process.env.MOCK_REDIS === "true";

if (useMock) {
log.info("Using mock Redis for escrow funding lock");
_client = new MockRedis() as unknown as Redis;
} else {
const url = process.env.REDIS_URL;
_client = url
? new Redis(url)
: new Redis({
host: process.env.REDIS_HOST ?? "localhost",
port: Number(process.env.REDIS_PORT ?? 6379),
});

_client.on("error", (err: Error) => {
log.error("Redis connection error in escrow lock", { error: err.message });
});
}
}
return _client;
}

/**
* Acquires a Redis SET NX PX distributed lock for the given orderId.
* Returns the lock on success, or null when another request already holds the lock.
* Throws LockServiceError when Redis is unreachable.
*/
export async function acquireLock(
orderId: string
): Promise<EscrowFundingLock | null> {
const key = `${LOCK_KEY_PREFIX}${orderId}`;
const lockToken = randomUUID();
const ttlMs = Number(
process.env.ESCROW_FUNDING_LOCK_TTL_MS ?? DEFAULT_LOCK_TTL_MS
);

let result: string | null;
try {
result = await getLockRedisClient().set(key, lockToken, "PX", ttlMs, "NX");
Comment on lines +68 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

A fixed TTL can reopen the critical section before deposit finishes.

This lock is acquired once and never renewed. If the protected deposit path runs longer than ESCROW_FUNDING_LOCK_TTL_MS, Redis will expire the key while the first request is still in flight, and a second request can reacquire it. To actually block duplicate in-flight funding, this needs lease renewal or a documented upper bound that is enforced for the entire operation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/payments/src/lock.ts` around lines 68 - 74, The lock in
getLockRedisClient().set is using a one-shot TTL that can expire while the
deposit flow is still running, allowing concurrent reacquisition of the same
key. Update the locking flow in this section of escrow funding lock handling to
either renew the lease for the duration of the protected operation or enforce a
hard upper bound on the total deposit time that is guaranteed to stay within the
TTL. Make sure the fix is applied around the lock acquisition and release logic
in the lock.ts path that uses ttlMs, lockToken, and result.

} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
log.error("Failed to acquire escrow funding lock — Redis unavailable", {
orderId,
error: message,
});
throw new LockServiceError(`Redis lock service unavailable: ${message}`);
}

if (result === null) {
log.warn("Escrow funding lock already held — duplicate request rejected", {
orderId,
});
return null;
}

log.info("Acquired escrow funding lock", { orderId, ttlMs });
return { orderId, lockToken, createdAt: Date.now() };
}

/**
* Releases the lock using a Lua script so only the owner (matching lockToken) can delete it.
* Failures are non-fatal: TTL will expire the key automatically.
*/
export async function releaseLock(
orderId: string,
lockToken: string
): Promise<void> {
const key = `${LOCK_KEY_PREFIX}${orderId}`;
try {
const deleted = (await getLockRedisClient().eval(
RELEASE_SCRIPT,
1,
key,
lockToken
)) as number;
if (deleted === 1) {
log.info("Released escrow funding lock", { orderId });
} else {
log.debug(
"Escrow funding lock not released — token mismatch or already expired",
{ orderId }
);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
log.warn(
"Failed to release escrow funding lock — will expire via TTL",
{ orderId, error: message }
);
}
}
38 changes: 38 additions & 0 deletions apps/backend/payments/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
validateReleaseRequest,
type ValidationError,
} from "./validation.js";
import {
acquireLock,
releaseLock,
LockServiceError,
type EscrowFundingLock,
} from "./lock.js";

async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -84,6 +90,7 @@ export function registerRoutes(): Route[] {
}),

route("POST", "/escrow/deposit", async (req, res) => {
let lock: EscrowFundingLock | null = null;
try {
const idempotency = validateIdempotencyKey(req.headers as Record<string, string | string[] | undefined>, "/escrow/deposit");
if (!idempotency.ok) {
Expand All @@ -98,6 +105,22 @@ export function registerRoutes(): Route[] {
}
if (!(await ensureContractConfig(res))) return;

// Acquire a distributed lock to prevent double-funding the same order.
if (validated.value.orderId) {
lock = await acquireLock(validated.value.orderId);
if (lock === null) {
json(res, 409, {
data: null,
error: {
code: "ESCROW_FUNDING_CONFLICT",
message:
"A funding request for this order is already in progress",
},
});
return;
}
}

Comment on lines +108 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline apps/backend/payments/src/validation.ts --view expanded
rg -n -C3 'validateDepositRequest|orderId' apps/backend/payments/src/validation.ts apps/backend/payments/src/routes.ts

Repository: DelegoLabs/Delego

Length of output: 5342


🏁 Script executed:

#!/bin/bash
sed -n '1,260p' apps/backend/payments/src/validation.ts
printf '\n--- routes ---\n'
sed -n '1,220p' apps/backend/payments/src/routes.ts

Repository: DelegoLabs/Delego

Length of output: 13642


🏁 Script executed:

#!/bin/bash
sed -n '1,260p' apps/backend/payments/src/validation.ts
printf '\n--- routes ---\n'
sed -n '1,220p' apps/backend/payments/src/routes.ts

Repository: DelegoLabs/Delego

Length of output: 13642


🏁 Script executed:

#!/bin/bash
sed -n '1,260p' apps/backend/payments/src/validation.ts
printf '\n--- routes ---\n'
sed -n '1,220p' apps/backend/payments/src/routes.ts

Repository: DelegoLabs/Delego

Length of output: 13642


Require a lock key for deposit requests

validateDepositRequest() allows orderId to be omitted, so this lock is skipped and concurrent deposits can still double-fund the same order. Make orderId required here, or derive the lock key from another required unique field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/payments/src/routes.ts` around lines 108 - 123, The deposit flow
in routes.ts skips distributed locking when validated.value.orderId is missing,
so concurrent requests can still double-fund the same order. Update the request
validation or handler so a lock key is always available for deposits: either
make orderId required in validateDepositRequest() and use it in acquireLock(),
or derive the lock key from another required unique field before entering the
lock block. Ensure the conflict response path in the funding handler remains
unchanged.

const result = await escrowService.deposit(validated.value);
json(res, 200, { data: result, error: null });
} catch (err) {
Expand All @@ -108,7 +131,22 @@ export function registerRoutes(): Route[] {
});
return;
}
if (err instanceof LockServiceError) {
json(res, 503, {
data: null,
error: {
code: "LOCK_SERVICE_UNAVAILABLE",
message:
"Funding lock service temporarily unavailable, please retry",
},
});
return;
}
sendOperationError(res, "ESCROW_DEPOSIT_FAILED", err);
} finally {
if (lock) {
await releaseLock(lock.orderId, lock.lockToken);
}
}
}),

Expand Down
6 changes: 6 additions & 0 deletions apps/backend/payments/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface ValidationError {
details?: Record<string, unknown>;
}

export interface EscrowFundingLock {
orderId: string;
lockToken: string;
createdAt: number;
}

export interface IdempotencyContext {
key: string;
route: string;
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions tests/unit/src/escrow-funding-lock.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, before, beforeEach } from "node:test";
import assert from "node:assert/strict";
import {
acquireLock,
releaseLock,
getLockRedisClient,
} from "../../../apps/backend/payments/dist/src/lock.js";

describe("EscrowFundingLock", () => {
before(() => {
process.env.NODE_ENV = "test";
process.env.MOCK_REDIS = "true";
});

beforeEach(async () => {
const redis = getLockRedisClient();
await redis.flushall();
});

it("acquires lock when orderId has no active funding request", async () => {
const lock = await acquireLock("order-001");

assert.ok(lock !== null, "should acquire lock on first attempt");
assert.equal(lock.orderId, "order-001");
assert.ok(
typeof lock.lockToken === "string" && lock.lockToken.length > 0,
"lockToken must be a non-empty string"
);
assert.ok(
typeof lock.createdAt === "number" && lock.createdAt > 0,
"createdAt must be a positive timestamp"
);
});

it("blocks a second concurrent lock request on the same orderId", async () => {
const lock1 = await acquireLock("order-concurrent");
assert.ok(lock1 !== null, "first attempt should acquire lock");

const lock2 = await acquireLock("order-concurrent");
assert.equal(lock2, null, "second concurrent attempt must be blocked");
});

it("allows different orderIds to acquire locks independently", async () => {
const lockA = await acquireLock("order-A");
const lockB = await acquireLock("order-B");

assert.ok(lockA !== null, "order-A should acquire its own lock");
assert.ok(lockB !== null, "order-B should acquire its own lock independently");
});

it("releases lock cleanly so next request can proceed", async () => {
const lock = await acquireLock("order-release");
assert.ok(lock !== null, "initial acquire should succeed");

await releaseLock(lock.orderId, lock.lockToken);

const lockAfter = await acquireLock("order-release");
assert.ok(lockAfter !== null, "should re-acquire lock after clean release");
});

it("does not release a lock owned by a different token", async () => {
const lock = await acquireLock("order-wrong-token");
assert.ok(lock !== null);

await releaseLock("order-wrong-token", "not-the-real-token");

// Lock should still be held because the wrong token was provided
const attempt = await acquireLock("order-wrong-token");
assert.equal(
attempt,
null,
"lock must still be held after wrong-token release attempt"
);
});

it("lock expires automatically after TTL allowing reacquisition", async () => {
const originalTtl = process.env.ESCROW_FUNDING_LOCK_TTL_MS;
process.env.ESCROW_FUNDING_LOCK_TTL_MS = "100"; // 100 ms for this test

try {
const lock = await acquireLock("order-ttl");
assert.ok(lock !== null, "initial acquire should succeed");

// Confirm lock blocks a second attempt before expiry
const blocked = await acquireLock("order-ttl");
assert.equal(blocked, null, "lock must block while TTL is active");

// Wait for TTL to expire
await new Promise((resolve) => setTimeout(resolve, 200));

const lockAfterExpiry = await acquireLock("order-ttl");
assert.ok(
lockAfterExpiry !== null,
"should re-acquire lock after TTL expiry"
);
} finally {
if (originalTtl !== undefined) {
process.env.ESCROW_FUNDING_LOCK_TTL_MS = originalTtl;
} else {
delete process.env.ESCROW_FUNDING_LOCK_TTL_MS;
}
}
});
});
Loading