Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dist/
.next/
out/
build/
*.tsbuildinfo

# Environment
.env
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- `POST /api/v1/webhooks/stellar` to receive incoming Stellar account/transaction event notifications, protected by HMAC-SHA256 signature verification (`X-Stellar-Webhook-Signature`, keyed with `STELLAR_WEBHOOK_SECRET`) that fails closed when unconfigured (#170).
- In-memory, per-account sequence number cache (`contracts/sequenceCache.ts`) shared by asset issuance, burn, trustline, and airdrop payment submission, so concurrent or back-to-back Stellar submissions from the same account no longer race on a stale sequence number. Falls back to a single reload-and-retry from Horizon on `tx_bad_seq` (#169).
- Expanded `api/utils/horizonError.ts` to map the full set of known Stellar transaction and operation result codes (`tx_bad_seq`, `op_underfunded`, `tx_too_late`, `op_low_reserve`, etc.) to friendly, actionable error messages, with full unit test coverage (#166).
- `GET /api/v1/prices/xlm` returning XLM/USD market price from public feeds with Redis caching and multi-provider failover (#137).
- `StellarService.isTestnet()` and `StellarService.isMainnet()` helper methods to inspect active Stellar network configuration (#141).
- `POST /api/v1/trustlines/build` to generate unsigned trustline establishment XDR for wallet signing (#124).
- `GET /api/v1/accounts/:publicKey` returning full Stellar account details from Horizon with Zod validation, retries, and mapped error codes (#122).
- `POST /api/v1/transactions/unsigned` to build unsigned Stellar payment XDR for wallet signing (#146).
- `GET /api/v1/balances/:publicKey/history` for paginated balance-change audit history from `transactions_log` (#145).
- `GET /api/v1/communities` pagination support via `page`, `limit`, and `offset` query parameters. When `offset` is provided, it takes precedence for querying and calculates the appropriate page in the metadata.
Expand Down Expand Up @@ -90,6 +97,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- Restored `backend/src/contracts/transactions.ts` (`buildUnsignedPayment`), which a prior cleanup commit deleted as unused dead code without also removing its only caller, `POST /api/v1/transactions/unsigned` — leaving the backend unable to compile or run its test suite.
- Error responses across `communities.ts`, `loans.ts`, `tokens.ts`, and the shared `validateBody`/`validateParams`/`validateQuery` middleware now consistently include `data: null`, matching the `{ data, meta?, error? }` envelope documented for the rest of the API
- `docs/openapi.yaml`: added the previously undocumented Communities list/search/create, full Tokens surface (burn, trustline, community listing, holders, supply, history), Loans lifecycle, and Balances loan endpoints, and fixed several broken `$ref` pointers (`CommunityId`/`Page`/`Limit` parameters and `IssueToken`/`TokenMetadata` schemas were referenced but never defined)
- Migration 019: `members_role_check` is now re-established with a preceding `DROP CONSTRAINT IF EXISTS`, so the role contract (`admin`/`treasurer`/`member`/`observer`) is replay-safe
Expand Down
11 changes: 6 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,24 +417,25 @@ We will acknowledge within 24 hours and disclose responsibly after a fix is depl

## 12. Maintainer Responsibilities

Maintainers are community members with write access to the main repository. Their responsibilities:
[BigNathan1](https://github.com/BigNathan1) is the sole maintainer of this repository, with the only
write access to `main`. Responsibilities:

- **Triage new issues** within 72 hours (add labels, request clarification, or close as duplicate)
- **Review PRs** within 72 hours of opening or update
- **Enforce** the branch model and commit convention on all merges
- **Maintain** the `main` branch in a deployable state at all times
- **Keep** the roadmap in PRD.md current each quarter
- **Release** tagged versions (`v0.x.y`) monthly during active development phases
- **Rotate** maintainer access reviews every 6 months

### Becoming a Maintainer

Sustained contributors (5+ merged PRs, positive community engagement) may be nominated by existing maintainers. Nominations are approved by simple majority of current maintainers.
Contributors are recognized on the [Contributors leaderboard](CONTRIBUTORS.md) rather than through
maintainer nomination — there is no path to write access via contribution volume.

---

## Thank You

Every contribution matters — whether it's fixing a typo, adding a test, or building a new lending flow. We're building something that can genuinely improve financial access for underserved communities worldwide. We're glad you're here.

Merged PRs earn recognition on the [Contributors leaderboard](CONTRIBUTORS.md).

**Happy building. ◆**
25 changes: 25 additions & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Contributors

CoopLumen recognizes external contributions with a simple points tally, awarded by the maintainer
on merge. This is recognition, not a governance mechanism — see [CONTRIBUTING.md](CONTRIBUTING.md)
for how the project is run.

## How points are awarded

Points are assigned per merged PR based on scope, judged by the maintainer at merge time:

| Points | Scope |
| ------ | ------------------------------------------------------------------------------- |
| 1–2 | Small fix, docs correction, or single small test |
| 3–5 | One feature/endpoint, a focused refactor, or a meaningful test suite addition |
| 6–10 | Multi-part PR closing several issues, a new subsystem, or foundational plumbing |

## Leaderboard

| Contributor | Points | Merged PRs |
| ------------------------------------- | -----: | ---------- |
| [Hallab7](https://github.com/Hallab7) | 8 | #581 |

## Ledger

- **2026-08-27** — [Hallab7](https://github.com/Hallab7) — **+8 points** — [#581](https://github.com/BigNathan1/CoopLumen/pull/581) _"add Stellar transaction and database foundation work"_ (merged via [#583](https://github.com/BigNathan1/CoopLumen/pull/583)): a new unsigned-payment XDR endpoint, a paginated balance-history audit endpoint, a completed database ERD, and a genuinely-fresh-database migration integration suite — four issues (#54, #56, #145, #146) closed in one well-tested, well-documented PR.
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,10 @@ PGPOOL_CONNECTION_TIMEOUT=2000 # Connection timeout in milliseconds (2s)
STELLAR_NETWORK=testnet
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org

# Shared secret for verifying the HMAC-SHA256 signature on incoming
# POST /api/v1/webhooks/stellar requests. Required in production; requests
# are rejected with 503 when unset.
STELLAR_WEBHOOK_SECRET=

# Frontend origin (for CORS)
FRONTEND_URL=http://localhost:3000
147 changes: 147 additions & 0 deletions backend/src/api/middleware/__tests__/webhookSignature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { createHmac } from 'crypto';
import { Request, Response } from 'express';
import {
verifyWebhookSignature,
STELLAR_WEBHOOK_SIGNATURE_HEADER,
} from '../webhookSignature';

jest.mock('../../../utils/logger', () => ({
logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
}));

const SECRET = 'test-webhook-secret';

function sign(secret: string, payload: Buffer): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}

function mockReqRes(opts: { body: Buffer; signature?: string }): {
req: Request;
res: Response;
next: jest.Mock;
json: jest.Mock;
status: jest.Mock;
} {
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const headers: Record<string, string> = {};
if (opts.signature !== undefined) {
headers[STELLAR_WEBHOOK_SIGNATURE_HEADER] = opts.signature;
}

const req = {
header: (name: string) => headers[name.toLowerCase()],
rawBody: opts.body,
} as unknown as Request;
const res = { status } as unknown as Response;
const next = jest.fn();

return { req, res, next, json, status };
}

describe('verifyWebhookSignature', () => {
const originalSecret = process.env.STELLAR_WEBHOOK_SECRET;

beforeEach(() => {
process.env.STELLAR_WEBHOOK_SECRET = SECRET;
});

afterAll(() => {
process.env.STELLAR_WEBHOOK_SECRET = originalSecret;
});

it('calls next() when the signature matches the raw body', () => {
const body = Buffer.from(JSON.stringify({ eventId: 'evt_1' }));
const { req, res, next, status } = mockReqRes({ body, signature: sign(SECRET, body) });

verifyWebhookSignature(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(status).not.toHaveBeenCalled();
});

it('rejects with 401 when the signature header is missing', () => {
const body = Buffer.from('{}');
const { req, res, next, status, json } = mockReqRes({ body });

verifyWebhookSignature(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('Missing') })
);
});

it('rejects with 401 when the signature does not match', () => {
const body = Buffer.from(JSON.stringify({ eventId: 'evt_1' }));
const { req, res, next, status, json } = mockReqRes({ body, signature: 'deadbeef'.repeat(8) });

verifyWebhookSignature(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Invalid webhook signature.' })
);
});

it('rejects with 401 when the signature is not valid hex (does not throw)', () => {
const body = Buffer.from(JSON.stringify({ eventId: 'evt_1' }));
const { req, res, next, status } = mockReqRes({ body, signature: 'not-hex!!' });

expect(() => verifyWebhookSignature(req, res, next)).not.toThrow();
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});

it('rejects with 401 when the signature is signed with the wrong secret', () => {
const body = Buffer.from(JSON.stringify({ eventId: 'evt_1' }));
const { req, res, next, status } = mockReqRes({ body, signature: sign('wrong-secret', body) });

verifyWebhookSignature(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});

it('detects tampering: a signature valid for one payload is rejected for another', () => {
const originalBody = Buffer.from(JSON.stringify({ eventId: 'evt_1', amount: '10' }));
const tamperedBody = Buffer.from(JSON.stringify({ eventId: 'evt_1', amount: '10000' }));
const { req, res, next, status } = mockReqRes({
body: tamperedBody,
signature: sign(SECRET, originalBody),
});

verifyWebhookSignature(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});

it('fails closed with 503 when no secret is configured', () => {
delete process.env.STELLAR_WEBHOOK_SECRET;
const body = Buffer.from('{}');
const { req, res, next, status, json } = mockReqRes({ body, signature: 'abcd' });

verifyWebhookSignature(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(503);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('not configured') })
);
});

it('returns 500 when the raw body was not captured upstream', () => {
const { req, res, next, status } = mockReqRes({
body: undefined as unknown as Buffer,
signature: 'abcd',
});

verifyWebhookSignature(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(500);
});
});
86 changes: 86 additions & 0 deletions backend/src/api/middleware/webhookSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../utils/logger';

export const STELLAR_WEBHOOK_SIGNATURE_HEADER = 'x-stellar-webhook-signature';

interface RequestWithRawBody extends Request {
rawBody?: Buffer;
}

function computeSignature(secret: string, payload: Buffer): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}

/**
* Compares two hex-encoded HMAC digests in constant time. Falls back to a
* length check (which is not itself timing-safe, but leaks nothing about the
* secret) when the strings can't be compared because their lengths differ,
* since `timingSafeEqual` throws on mismatched buffer lengths.
*/
function signaturesMatch(expected: string, provided: string): boolean {
const expectedBuffer = Buffer.from(expected, 'hex');
const providedBuffer = Buffer.from(provided, 'hex');

if (expectedBuffer.length !== providedBuffer.length) {
return false;
}

return timingSafeEqual(expectedBuffer, providedBuffer);
}

/**
* Verifies the `X-Stellar-Webhook-Signature` header against an HMAC-SHA256
* digest of the raw request body, computed with `STELLAR_WEBHOOK_SECRET`.
* Rejects the request with 401 when the signature is missing, malformed, or
* does not match, and with 503 when the server has no secret configured
* (misconfiguration should fail closed, not silently accept anything).
*
* Must run after the raw-body-capturing `express.json({ verify })` in
* app.ts — it verifies over the exact bytes received, not a re-serialized
* copy of the parsed body.
*/
export function verifyWebhookSignature(req: Request, res: Response, next: NextFunction): void {
const secret = process.env.STELLAR_WEBHOOK_SECRET;
if (!secret) {
logger.error('STELLAR_WEBHOOK_SECRET is not configured; rejecting webhook request');
res.status(503).json({
data: null,
error: 'Webhook signature verification is not configured on this server.',
});
return;
}

const signatureHeader = req.header(STELLAR_WEBHOOK_SIGNATURE_HEADER);
if (!signatureHeader) {
res.status(401).json({
data: null,
error: `Missing ${STELLAR_WEBHOOK_SIGNATURE_HEADER} header.`,
});
return;
}

const rawBody = (req as RequestWithRawBody).rawBody;
if (!rawBody) {
logger.error('Webhook signature check ran without a captured raw request body');
res.status(500).json({ data: null, error: 'Unable to verify webhook signature.' });
return;
}

const expectedSignature = computeSignature(secret, rawBody);

let isValid: boolean;
try {
isValid = signaturesMatch(expectedSignature, signatureHeader);
} catch {
isValid = false;
}

if (!isValid) {
logger.warn('Rejected webhook request with an invalid signature');
res.status(401).json({ data: null, error: 'Invalid webhook signature.' });
return;
}

next();
}
47 changes: 47 additions & 0 deletions backend/src/api/routes/__tests__/accounts.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Integration test: verifies loading account details from Stellar testnet Horizon.
* Skipped gracefully when Horizon testnet is not reachable.
*/

import request from 'supertest';
import { Keypair } from '@stellar/stellar-sdk';
import app from '../../../app';
import { StellarService } from '../../../contracts/stellar';

describe('Accounts testnet integration', () => {
let isTestnetReachable = false;
// Well-known persistent testnet account
const testnetPublicKey = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';

beforeAll(async () => {
isTestnetReachable = await StellarService.ping();
});

it('fetches real account details from Stellar testnet', async () => {
if (!isTestnetReachable) {
return;
}

const response = await request(app).get(`/api/v1/accounts/${testnetPublicKey}`);
expect(response.status).toBe(200);
expect(response.body.data).toBeDefined();
expect(response.body.data.id).toBe(testnetPublicKey);
expect(response.body.data.account_id).toBe(testnetPublicKey);
expect(Array.isArray(response.body.data.balances)).toBe(true);
expect(Array.isArray(response.body.data.signers)).toBe(true);
expect(typeof response.body.data.sequence).toBe('string');
});

it('returns 404 for an unfunded valid public key on testnet', async () => {
if (!isTestnetReachable) {
return;
}

// Unfunded random valid public key
const unfundedKey = Keypair.random().publicKey();
const response = await request(app).get(`/api/v1/accounts/${unfundedKey}`);
expect(response.status).toBe(404);
expect(response.body.data).toBeNull();
expect(response.body.error).toBe('Stellar account or asset not found.');
});
});
Loading
Loading