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
46 changes: 40 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,41 @@ cargo build

---

## 4. Branching
## 4. Pre-commit Hooks

StellarLend uses [Husky](https://typicode.github.io/husky/) and [lint-staged](https://github.com/lint-staged/lint-staged) to enforce code quality before every commit.

### What runs on commit

| Staged files | Checks |
|-----------------------|---------------------------------------------|
| `api/src/**/*.ts` | ESLint, Prettier format check, TypeScript |
| `oracle/src/**/*.ts` | ESLint, Prettier format check, TypeScript |
| `contract/**/*.rs` | `cargo check` |

Commit messages are also validated against the [Conventional Commits](https://www.conventionalcommits.org/) spec.

### Setup

After cloning, install root dependencies to activate the hooks:

```bash
npm install
```

Husky is initialised automatically via the `prepare` script.

### Bypassing hooks (emergencies only)

```bash
git commit --no-verify -m "fix: emergency patch"
```

> **Note:** `--no-verify` skips both the pre-commit and commit-msg hooks. Use only when necessary.

---

## 5. Branching

Use clear and descriptive branch names:

Expand All @@ -127,7 +161,7 @@ git checkout -b fix/issue-name

---

## 5. Commit Messages
## 6. Commit Messages

Use the format:

Expand All @@ -142,7 +176,7 @@ Examples:

---

## 6. Pull Request Process
## 7. Pull Request Process

1. Fork the repository
2. Create a new branch
Expand All @@ -164,7 +198,7 @@ Closes #123

---

## 7. Testing
## 8. Testing

Run tests before submitting:

Expand All @@ -191,7 +225,7 @@ cargo test

---

## 8. Guidelines
## 9. Guidelines

* Do not commit `.env` files
* Do not expose secrets in logs or responses
Expand All @@ -200,7 +234,7 @@ cargo test

---

## 9. Additional Resources
## 10. Additional Resources

* Stellar Docs: https://developers.stellar.org/
* Soroban Docs: https://soroban.stellar.org/
Expand Down
2 changes: 1 addition & 1 deletion api/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleNameMapping: {
moduleNameMapper: {
'^@/config/(.*)$': '<rootDir>/src/config/$1',
'^@/controllers/(.*)$': '<rootDir>/src/controllers/$1',
'^@/middleware/(.*)$': '<rootDir>/src/middleware/$1',
Expand Down
4 changes: 3 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@types/ws": "^8.18.1",
"axios": "^1.6.2",
"bcryptjs": "^2.4.3",
"cls-hooked": "^4.2.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.18.2",
Expand All @@ -40,6 +41,7 @@
"jsonwebtoken": "^9.0.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"ws": "^8.20.0"
},
Expand All @@ -61,8 +63,8 @@
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-plugin-import": "^2.29.0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-import": "^2.29.0",
"husky": "^9.1.4",
"jest": "^29.7.0",
"lint-staged": "^15.2.7",
Expand Down
146 changes: 146 additions & 0 deletions api/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,3 +625,149 @@ describe('IP-based Rate Limiting (Outer Layer)', () => {
expect(statuses.some((s: number) => s === 429)).toBe(true);
});
});

// ─── 9. Concurrent Request Handling ──────────────────────────────────────────
//
// Race condition coverage:
// - Simultaneous deposits from the same user must each get a unique response
// - No double-processing: submitTransaction called exactly once per request
// - Idempotency key deduplication holds under concurrency
// - Concurrent requests from different users are isolated

describe('Concurrent Request Handling', () => {
it('processes simultaneous deposits independently without double-processing', async () => {
const CONCURRENCY = 5;

const responses = await Promise.all(
Array.from({ length: CONCURRENCY }, () =>
request(app)
.get('/api/lending/prepare/deposit')
.query({ userAddress: VALID_ADDRESS, amount: VALID_AMOUNT })
)
);

// Every request must succeed
responses.forEach((res) => {
expect(res.status).toBe(200);
expect(res.body.unsignedXdr).toBe('unsigned_xdr_string');
expect(res.body.operation).toBe('deposit');
});

// buildUnsignedTransaction called exactly once per concurrent request — no merging
expect(mockStellarService.buildUnsignedTransaction).toHaveBeenCalledTimes(CONCURRENCY);
});

it('submits concurrent transactions without double-processing', async () => {
const CONCURRENCY = 5;

const responses = await Promise.all(
Array.from({ length: CONCURRENCY }, () =>
request(app)
.post('/api/lending/submit')
.send({ signedXdr: 'signed_xdr_payload' })
)
);

// All must succeed
responses.forEach((res) => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.transactionHash).toBe('abc123txhash');
});

// submitTransaction called exactly once per request — no batching or skipping
expect(mockStellarService.submitTransaction).toHaveBeenCalledTimes(CONCURRENCY);
expect(mockStellarService.monitorTransaction).toHaveBeenCalledTimes(CONCURRENCY);
});

it('deduplicates concurrent requests sharing the same idempotency key', async () => {
const idemKey = 'a1b2c3d4-e5f6-4890-abcd-ef1234567890';
const CONCURRENCY = 5;

const responses = await Promise.all(
Array.from({ length: CONCURRENCY }, () =>
request(app)
.post('/api/lending/submit')
.set('Idempotency-Key', idemKey)
.send({ signedXdr: 'signed_xdr_payload' })
)
);

// All responses must be 200
responses.forEach((res) => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});

// Despite 5 concurrent requests, the transaction is submitted only once
expect(mockStellarService.submitTransaction).toHaveBeenCalledTimes(1);

// All responses carry the same transaction hash
const hashes = responses.map((r) => r.body.transactionHash);
expect(new Set(hashes).size).toBe(1);
});

it('isolates concurrent requests from different users', async () => {
const USER_A = 'GDZZJ3UPZZCKY5DBH6ZGMPMRORRBG4ECIORASBUAXPPNCL4SYRHNLYU2';
const USER_B = 'GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB';

const [userAResponses, userBResponses] = await Promise.all([
Promise.all(
Array.from({ length: 3 }, () =>
request(app)
.get('/api/lending/prepare/deposit')
.query({ userAddress: USER_A, amount: VALID_AMOUNT })
)
),
Promise.all(
Array.from({ length: 3 }, () =>
request(app)
.get('/api/lending/prepare/deposit')
.query({ userAddress: USER_B, amount: VALID_AMOUNT })
)
),
]);

// Both users get successful responses
[...userAResponses, ...userBResponses].forEach((res) => {
expect(res.status).toBe(200);
});

// Each request triggers exactly one service call — 6 total
expect(mockStellarService.buildUnsignedTransaction).toHaveBeenCalledTimes(6);

// User A's calls used USER_A address
const userACalls = mockStellarService.buildUnsignedTransaction.mock.calls.filter(
(args) => args[1] === USER_A
);
expect(userACalls).toHaveLength(3);

// User B's calls used USER_B address
const userBCalls = mockStellarService.buildUnsignedTransaction.mock.calls.filter(
(args) => args[1] === USER_B
);
expect(userBCalls).toHaveLength(3);
});

it('handles concurrent failures without affecting successful requests', async () => {
// First call fails, rest succeed
mockStellarService.buildUnsignedTransaction
.mockRejectedValueOnce(new Error('Stellar network error'))
.mockResolvedValue('unsigned_xdr_string');

const responses = await Promise.all(
Array.from({ length: 4 }, () =>
request(app)
.get('/api/lending/prepare/deposit')
.query({ userAddress: VALID_ADDRESS, amount: VALID_AMOUNT })
)
);

const statuses = responses.map((r) => r.status);

// Exactly one failure
expect(statuses.filter((s) => s === 500)).toHaveLength(1);
// Remaining three succeed
expect(statuses.filter((s) => s === 200)).toHaveLength(3);
});
});
Loading
Loading