Skip to content

feat(payments): add double-spend validation for escrow funding requests#234

Open
MorsH14 wants to merge 3 commits into
DelegoLabs:mainfrom
MorsH14:feat/payments-escrow-funding-lock
Open

feat(payments): add double-spend validation for escrow funding requests#234
MorsH14 wants to merge 3 commits into
DelegoLabs:mainfrom
MorsH14:feat/payments-escrow-funding-lock

Conversation

@MorsH14

@MorsH14 MorsH14 commented Jun 25, 2026

Copy link
Copy Markdown

Summary

  • Implements a Redis distributed lock (SET NX PX) on the /escrow/deposit route to prevent concurrent requests from double-funding the same order (closes [Payments] Add Double-Spend Validation for Escrow Funding Requests #51)
  • New src/lock.ts module exports acquireLock / releaseLock with a Lua script for atomic token-checked deletion, and LockServiceError for Redis unavailability
  • EscrowFundingLock interface added to src/validation.ts per implementation scope
  • Deposit route returns 409 ESCROW_FUNDING_CONFLICT for in-flight duplicate requests and 503 LOCK_SERVICE_UNAVAILABLE when Redis is unreachable
  • Lock auto-expires via configurable ESCROW_FUNDING_LOCK_TTL_MS (default 30 s) so a crash cannot permanently block an order
  • Added ioredis (prod dep) and ioredis-mock (dev dep) to the payments service
  • 6 unit tests covering: initial acquisition, concurrent blocking, independent-order isolation, clean release, wrong-token guard, and TTL auto-expiry
  • README updated with full env-var table and lock behaviour documentation

Design decisions

Decision Rationale
Lock only when orderId present Without an order identifier there is nothing to deduplicate; existing callers that omit orderId are unaffected
Lua script release Atomically ensures only the holder can delete the key — no TOCTOU window
LockServiceError → 503 Redis downtime should be visible to callers so they can retry rather than silently proceeding
TTL default 30 s Covers the full Soroban tx lifecycle (simulate → submit → poll) with margin
Defense in depth The Soroban escrow contract enforces on-chain uniqueness as a second independent guard

Test plan

  • node --test tests/unit/src/escrow-funding-lock.test.js — 6/6 pass
  • node --test tests/unit/src/payments-validation.test.js — 17/17 pass (no regressions)
  • pnpm --filter @delego/payments build — clean TypeScript compile
  • pnpm -r typecheck — all packages pass
  • Integration test (tests/integration/src/payments-escrow.testnet.test.js) requires a live Stellar testnet and deployed escrow contract — not run locally

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Redis-backed escrow funding locking to prevent duplicate in-progress deposits.
    • Expanded API documentation with configuration settings, lock behavior, and route details.
    • Added automated coverage for lock acquisition, release, conflict handling, and expiry.
  • Bug Fixes
    • Deposit requests now return clear errors when a funding lock is already held or the lock service is unavailable.
    • Locks are safely released after deposit processing to reduce stale-lock issues.
  • Chores
    • Updated service dependencies to support Redis in runtime and test environments.

Implements a Redis distributed lock (SET NX PX) around the /escrow/deposit
route to prevent race conditions from funding the same order twice. A Lua
script ensures only the token owner can release the lock atomically.

- Add EscrowFundingLock interface to validation.ts
- New lock.ts: acquireLock / releaseLock with LockServiceError for Redis failures
- Deposit route returns 409 ESCROW_FUNDING_CONFLICT for in-flight duplicates
  and 503 LOCK_SERVICE_UNAVAILABLE when Redis is unreachable
- Lock auto-expires via configurable ESCROW_FUNDING_LOCK_TTL_MS (default 30s)
- Add ioredis + ioredis-mock dependencies to payments service
- 6 unit tests covering concurrent blocking, clean release, wrong-token guard,
  and TTL auto-expiry

Closes DelegoLabs#51

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cdc4e9fb-8b70-470d-a258-5e23b739f17d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Payments now uses a Redis-backed escrow funding lock for deposit requests, adds lock acquisition/release helpers and related configuration docs, and includes unit tests covering duplicate requests, release behavior, and TTL expiry.

Changes

Escrow Funding Lock

Layer / File(s) Summary
Lock contract and Redis bootstrap
apps/backend/payments/src/validation.ts, apps/backend/payments/package.json, apps/backend/payments/src/lock.ts, apps/backend/payments/README.md
Adds the escrow funding lock shape, Redis client setup, lock error handling, Redis dependencies, and environment/lock documentation.
Lock acquisition and release
apps/backend/payments/src/lock.ts
Creates and clears Redis locks with per-order tokens, atomic SET NX PX, TTL handling, and token-checked Lua deletion.
Deposit route enforcement
apps/backend/payments/src/routes.ts, apps/backend/payments/README.md
Wraps escrow deposit handling with lock acquisition, returns 409 on conflicting in-flight funding, maps lock service failures to 503, and documents the escrow API route locking/idempotency notes.
Lock lifecycle tests
tests/unit/src/escrow-funding-lock.test.js
Covers successful acquisition, duplicate rejection, independent order locks, release and ownership checks, and TTL expiration behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • DelegoLabs/Delego#147 — Both changes touch apps/backend/payments/src/routes.ts deposit request handling and add request-level gating before the escrow mutation runs.

Poem

A bunny hopped through Redis lanes 🐇
One lock for one order, no doubling pains.
If a token fits right, the gate swings free,
If not, the hop ends at 409’s tree.
Then off I bound, with TTL dreams in my ears.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the escrow funding double-spend protection added in this PR.
Linked Issues check ✅ Passed The PR adds Redis locking, conflict handling, tests, and documentation for escrow funding double-spend protection as requested.
Out of Scope Changes check ✅ Passed All shown changes support escrow funding lock behavior or its tests/docs; no unrelated code changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@drips-wave

drips-wave Bot commented Jun 25, 2026

Copy link
Copy Markdown

@MorsH14 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@apps/backend/payments/src/lock.ts`:
- Around line 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.
- Around line 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.

In `@apps/backend/payments/src/routes.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 17bf5eb2-8322-430c-bdee-18e2f4423104

📥 Commits

Reviewing files that changed from the base of the PR and between f54d66b and 29fe726.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (6)
  • apps/backend/payments/README.md
  • apps/backend/payments/package.json
  • apps/backend/payments/src/lock.ts
  • apps/backend/payments/src/routes.ts
  • apps/backend/payments/src/validation.ts
  • tests/unit/src/escrow-funding-lock.test.js

Comment on lines +2 to +4
import { Redis } from "ioredis";
// @ts-ignore — ioredis-mock is a dev dependency used only in test/mock mode
import MockRedis from "ioredis-mock";

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.

Comment on lines +68 to +74
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");

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.

Comment on lines +108 to +123
// 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;
}
}

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.

@ScriptedBro

Copy link
Copy Markdown
Contributor

Fix the failing ci

@ScriptedBro

Copy link
Copy Markdown
Contributor

Resolve conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Payments] Add Double-Spend Validation for Escrow Funding Requests

2 participants