diff --git a/.gitignore b/.gitignore index 1e5e0d5..23f953a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist/ .next/ out/ build/ +*.tsbuildinfo # Environment .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ff3b3..782e419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2aaed7..d455d6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -417,7 +417,8 @@ 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 @@ -425,11 +426,9 @@ Maintainers are community members with write access to the main repository. Thei - **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. --- @@ -437,4 +436,6 @@ Sustained contributors (5+ merged PRs, positive community engagement) may be nom 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. ◆** diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..6e921e2 --- /dev/null +++ b/CONTRIBUTORS.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example index 12f5a0c..f73c893 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/api/middleware/__tests__/webhookSignature.test.ts b/backend/src/api/middleware/__tests__/webhookSignature.test.ts new file mode 100644 index 0000000..b9bfc21 --- /dev/null +++ b/backend/src/api/middleware/__tests__/webhookSignature.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/backend/src/api/middleware/webhookSignature.ts b/backend/src/api/middleware/webhookSignature.ts new file mode 100644 index 0000000..5bf5d73 --- /dev/null +++ b/backend/src/api/middleware/webhookSignature.ts @@ -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(); +} diff --git a/backend/src/api/routes/__tests__/accounts.integration.test.ts b/backend/src/api/routes/__tests__/accounts.integration.test.ts new file mode 100644 index 0000000..393bb33 --- /dev/null +++ b/backend/src/api/routes/__tests__/accounts.integration.test.ts @@ -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.'); + }); +}); diff --git a/backend/src/api/routes/__tests__/accounts.test.ts b/backend/src/api/routes/__tests__/accounts.test.ts new file mode 100644 index 0000000..8cee4a1 --- /dev/null +++ b/backend/src/api/routes/__tests__/accounts.test.ts @@ -0,0 +1,288 @@ +import request from 'supertest'; +import { Keypair } from '@stellar/stellar-sdk'; + +jest.mock('../../../db', () => ({ + db: { + ping: jest.fn().mockResolvedValue(true), + }, +})); + +jest.mock('../../../utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }, +})); + +import app from '../../../app'; +import { StellarService } from '../../../contracts/stellar'; + +const publicKey = Keypair.random().publicKey(); + +function setMockServer(server: unknown): void { + (StellarService as unknown as { server: unknown }).server = server; +} + +function runTimeoutsImmediately(): jest.SpyInstance { + return jest.spyOn(global, 'setTimeout').mockImplementation((( + callback: (...args: unknown[]) => void + ) => { + if (typeof callback === 'function') { + callback(); + } + + return 0 as unknown as ReturnType; + }) as typeof setTimeout); +} + +describe('accounts routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('GET /api/v1/accounts/:publicKey', () => { + it('returns a validation error for an invalid public key', async () => { + const loadAccount = jest.fn(); + setMockServer({ loadAccount }); + + const response = await request(app).get('/api/v1/accounts/not-a-stellar-key'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + data: null, + error: 'Validation failed', + meta: { + errors: [ + { + path: 'publicKey', + message: 'publicKey must be a valid Stellar public key', + }, + ], + }, + }); + expect(loadAccount).not.toHaveBeenCalled(); + }); + + it('returns full account details from Horizon for a valid public key', async () => { + const mockAccount = { + id: publicKey, + account_id: publicKey, + sequence: '1000001', + sequence_ledger: 12345, + sequence_time: '2026-08-27T12:00:00Z', + subentry_count: 3, + home_domain: 'cooplumen.org', + inflation_destination: publicKey, + last_modified_ledger: 12350, + last_modified_time: '2026-08-27T12:05:00Z', + thresholds: { + low_threshold: 0, + med_threshold: 1, + high_threshold: 2, + }, + flags: { + auth_required: false, + auth_revocable: false, + auth_immutable: false, + auth_clawback_enabled: false, + }, + balances: [ + { + asset_type: 'native', + balance: '500.0000000', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + }, + { + asset_type: 'credit_alphanum4', + asset_code: 'COOP', + asset_issuer: publicKey, + balance: '1000.0000000', + limit: '922337203685.4775807', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + last_modified_ledger: 12300, + is_authorized: true, + is_authorized_to_maintain_liabilities: true, + }, + ], + signers: [ + { + key: publicKey, + weight: 1, + type: 'ed25519_public_key', + }, + ], + data_attr: { + community_role: 'YWRtaW4=', + }, + num_sponsoring: 0, + num_sponsored: 0, + paging_token: publicKey, + }; + + const loadAccount = jest.fn().mockResolvedValue(mockAccount); + setMockServer({ loadAccount }); + + const response = await request(app).get(`/api/v1/accounts/${publicKey}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: { + id: publicKey, + account_id: publicKey, + sequence: '1000001', + sequence_ledger: 12345, + sequence_time: '2026-08-27T12:00:00Z', + subentry_count: 3, + home_domain: 'cooplumen.org', + inflation_destination: publicKey, + last_modified_ledger: 12350, + last_modified_time: '2026-08-27T12:05:00Z', + thresholds: { + low_threshold: 0, + med_threshold: 1, + high_threshold: 2, + }, + flags: { + auth_required: false, + auth_revocable: false, + auth_immutable: false, + auth_clawback_enabled: false, + }, + balances: [ + { + asset_type: 'native', + balance: '500.0000000', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + }, + { + asset_type: 'credit_alphanum4', + asset_code: 'COOP', + asset_issuer: publicKey, + balance: '1000.0000000', + limit: '922337203685.4775807', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + last_modified_ledger: 12300, + is_authorized: true, + is_authorized_to_maintain_liabilities: true, + }, + ], + signers: [ + { + key: publicKey, + weight: 1, + type: 'ed25519_public_key', + }, + ], + data: { + community_role: 'YWRtaW4=', + }, + num_sponsoring: 0, + num_sponsored: 0, + paging_token: publicKey, + }, + }); + expect(loadAccount).toHaveBeenCalledWith(publicKey); + }); + + it('returns a 404 when the account is not found on Stellar network', async () => { + const loadAccount = jest.fn().mockRejectedValue({ response: { status: 404 } }); + setMockServer({ loadAccount }); + + const response = await request(app).get(`/api/v1/accounts/${publicKey}`); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + data: null, + error: 'Stellar account or asset not found.', + }); + }); + + it('retries Horizon 429 rate-limiting failures and succeeds', async () => { + const setTimeoutSpy = runTimeoutsImmediately(); + const mockAccount = { + id: publicKey, + account_id: publicKey, + sequence: '500', + subentry_count: 0, + last_modified_ledger: 100, + thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, + flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, + balances: [{ asset_type: 'native', balance: '10.0000000' }], + signers: [{ key: publicKey, weight: 1, type: 'ed25519_public_key' }], + data_attr: {}, + }; + + const loadAccount = jest + .fn() + .mockRejectedValueOnce({ response: { status: 429 } }) + .mockRejectedValueOnce({ response: { status: 429 } }) + .mockResolvedValueOnce(mockAccount); + setMockServer({ loadAccount }); + + const response = await request(app).get(`/api/v1/accounts/${publicKey}`); + + expect(response.status).toBe(200); + expect(response.body.data.sequence).toBe('500'); + expect(loadAccount).toHaveBeenCalledTimes(3); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 100); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 200); + }); + + it('retries Horizon 503 service unavailable failures and succeeds', async () => { + const setTimeoutSpy = runTimeoutsImmediately(); + const mockAccount = { + id: publicKey, + account_id: publicKey, + sequence: '800', + subentry_count: 0, + last_modified_ledger: 200, + thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, + flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, + balances: [{ asset_type: 'native', balance: '20.0000000' }], + signers: [], + data_attr: {}, + }; + + const loadAccount = jest + .fn() + .mockRejectedValueOnce({ response: { status: 503, headers: { 'retry-after': '0.2' } } }) + .mockResolvedValueOnce(mockAccount); + setMockServer({ loadAccount }); + + const response = await request(app).get(`/api/v1/accounts/${publicKey}`); + + expect(response.status).toBe(200); + expect(response.body.data.sequence).toBe('800'); + expect(loadAccount).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 200); + }); + + it('returns a 502 with mapped message when Horizon fails after retry exhaustion', async () => { + const setTimeoutSpy = runTimeoutsImmediately(); + const loadAccount = jest.fn().mockRejectedValue({ + response: { status: 503, data: { detail: 'Service temporarily overloaded' } }, + }); + setMockServer({ loadAccount }); + + const response = await request(app).get(`/api/v1/accounts/${publicKey}`); + + expect(response.status).toBe(502); + expect(response.body).toEqual({ + data: null, + error: 'Stellar network error: Service temporarily overloaded', + }); + expect(loadAccount).toHaveBeenCalledTimes(4); + expect(setTimeoutSpy).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/backend/src/api/routes/__tests__/prices.integration.test.ts b/backend/src/api/routes/__tests__/prices.integration.test.ts new file mode 100644 index 0000000..492ed31 --- /dev/null +++ b/backend/src/api/routes/__tests__/prices.integration.test.ts @@ -0,0 +1,26 @@ +/** + * Integration test: verifies fetching live XLM price from public source. + * Gracefully handles offline environments. + */ + +import request from 'supertest'; +import app from '../../../app'; + +describe('Prices live public integration', () => { + it('fetches real XLM/USD price from public market data feed', async () => { + const response = await request(app).get('/api/v1/prices/xlm'); + + // If network is available, status is 200; if network is blocked in sandbox, status is 502 + if (response.status === 200) { + expect(response.body.data).toBeDefined(); + expect(response.body.data.asset).toBe('XLM'); + expect(response.body.data.currency).toBe('USD'); + expect(typeof response.body.data.price).toBe('string'); + expect(Number(response.body.data.price)).toBeGreaterThan(0); + expect(typeof response.body.data.source).toBe('string'); + } else { + expect(response.status).toBe(502); + expect(response.body.error).toBe('Failed to fetch price from public source.'); + } + }); +}); diff --git a/backend/src/api/routes/__tests__/prices.test.ts b/backend/src/api/routes/__tests__/prices.test.ts new file mode 100644 index 0000000..c411aa0 --- /dev/null +++ b/backend/src/api/routes/__tests__/prices.test.ts @@ -0,0 +1,181 @@ +import request from 'supertest'; + +const mockRedisClient = { + connect: jest.fn().mockResolvedValue(undefined), + get: jest.fn(), + setEx: jest.fn(), + del: jest.fn(), + on: jest.fn(), + isOpen: true, +}; + +jest.mock('redis', () => ({ + createClient: jest.fn(() => mockRedisClient), +})); + +jest.mock('../../../db', () => ({ + db: { + ping: jest.fn().mockResolvedValue(true), + }, +})); + +jest.mock('../../../contracts/stellar', () => ({ + StellarService: { + ping: jest.fn().mockResolvedValue(true), + }, +})); + +jest.mock('../../../utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }, +})); + +import app from '../../../app'; +import { PRICE_CACHE_TTL_SECONDS, getPriceCacheKey } from '../../../cache/prices'; +import { redisCache } from '../../../cache/redis'; + +describe('prices routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.REDIS_URL = 'redis://cache.test:6379'; + mockRedisClient.connect.mockResolvedValue(undefined); + mockRedisClient.get.mockResolvedValue(null); + mockRedisClient.setEx.mockResolvedValue(undefined); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.on.mockReturnValue(mockRedisClient); + mockRedisClient.isOpen = true; + (redisCache as unknown as { client: unknown }).client = null; + (redisCache as unknown as { connectPromise: unknown }).connectPromise = null; + (redisCache as unknown as { hasLoggedDisabledState: boolean }).hasLoggedDisabledState = false; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('GET /api/v1/prices/xlm', () => { + it('returns cached XLM/USD price on a Redis cache hit', async () => { + const cachedPrice = { + asset: 'XLM', + currency: 'USD', + price: '0.1890000', + source: 'coingecko', + timestamp: '2026-08-27T16:00:00.000Z', + }; + mockRedisClient.get.mockResolvedValueOnce(JSON.stringify(cachedPrice)); + + const response = await request(app).get('/api/v1/prices/xlm'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ data: cachedPrice }); + expect(mockRedisClient.get).toHaveBeenCalledWith(getPriceCacheKey('XLM', 'USD')); + }); + + it('fetches from public provider on cache miss and stores in Redis', async () => { + mockRedisClient.get.mockResolvedValueOnce(null); + + const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ stellar: { usd: 0.1885 } }), + } as Response); + + const response = await request(app).get('/api/v1/prices/xlm'); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ + asset: 'XLM', + currency: 'USD', + price: '0.1885000', + source: 'coingecko', + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRedisClient.setEx).toHaveBeenCalledWith( + getPriceCacheKey('XLM', 'USD'), + PRICE_CACHE_TTL_SECONDS, + expect.any(String) + ); + }); + + it('supports custom currency query parameter and caches under currency key', async () => { + mockRedisClient.get.mockResolvedValueOnce(null); + + const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ stellar: { eur: 0.165 } }), + } as Response); + + const response = await request(app).get('/api/v1/prices/xlm?currency=EUR'); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ + asset: 'XLM', + currency: 'EUR', + price: '0.1650000', + source: 'coingecko', + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRedisClient.get).toHaveBeenCalledWith(getPriceCacheKey('XLM', 'EUR')); + }); + + it('returns validation error for invalid currency query parameter', async () => { + const response = await request(app).get('/api/v1/prices/xlm?currency=TOOLONG123'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + data: null, + error: 'Validation failed', + meta: { + errors: [ + { + path: 'currency', + message: 'currency must be a valid 3- or 4-letter currency code', + }, + ], + }, + }); + expect(mockRedisClient.get).not.toHaveBeenCalled(); + }); + + it('falls back to secondary provider when primary provider fails', async () => { + mockRedisClient.get.mockResolvedValueOnce(null); + + // 1st call (CoinGecko) fails, 2nd call (Binance) succeeds + const mockFetch = jest + .spyOn(global, 'fetch') + .mockRejectedValueOnce(new Error('CoinGecko timeout')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ price: '0.1889000' }), + } as Response); + + const response = await request(app).get('/api/v1/prices/xlm'); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ + asset: 'XLM', + currency: 'USD', + price: '0.1889000', + source: 'binance', + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('returns 502 Bad Gateway when all public price feeds fail', async () => { + mockRedisClient.get.mockResolvedValueOnce(null); + + jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network offline')); + + const response = await request(app).get('/api/v1/prices/xlm'); + + expect(response.status).toBe(502); + expect(response.body).toEqual({ + data: null, + error: 'Failed to fetch price from public source.', + }); + expect(mockRedisClient.setEx).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/api/routes/__tests__/trustlines.test.ts b/backend/src/api/routes/__tests__/trustlines.test.ts new file mode 100644 index 0000000..9d53e70 --- /dev/null +++ b/backend/src/api/routes/__tests__/trustlines.test.ts @@ -0,0 +1,190 @@ +import request from 'supertest'; +import { + Account, + Asset, + Keypair, + Networks, + Transaction, + TransactionBuilder, +} from '@stellar/stellar-sdk'; + +jest.mock('../../../db', () => ({ + db: { ping: jest.fn().mockResolvedValue(true) }, +})); + +jest.mock('../../../utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }, +})); + +import app from '../../../app'; +import { StellarService } from '../../../contracts/stellar'; + +const accountPublicKey = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)).publicKey(); +const assetIssuer = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey(); + +function setMockServer(server: unknown): void { + (StellarService as unknown as { server: unknown }).server = server; +} + +function runTimeoutsImmediately(): jest.SpyInstance { + return jest.spyOn(global, 'setTimeout').mockImplementation((( + callback: (...args: unknown[]) => void + ) => { + callback(); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); +} + +describe('POST /api/v1/trustlines/build', () => { + beforeEach(() => { + jest.clearAllMocks(); + (StellarService as unknown as { network: string }).network = Networks.TESTNET; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('builds an unsigned trustline establishment XDR from the Horizon sequence number', async () => { + const loadAccount = jest.fn().mockResolvedValue(new Account(accountPublicKey, '200')); + setMockServer({ loadAccount }); + + const response = await request(app).post('/api/v1/trustlines/build').send({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ data: { xdr: expect.any(String) } }); + expect(loadAccount).toHaveBeenCalledWith(accountPublicKey); + + const transaction = TransactionBuilder.fromXDR( + response.body.data.xdr, + Networks.TESTNET + ) as Transaction; + expect(transaction.sequence).toBe('201'); + expect(transaction.signatures).toHaveLength(0); + expect(transaction.operations).toHaveLength(1); + + const operation = transaction.operations[0]; + expect(operation.type).toBe('changeTrust'); + if (operation.type === 'changeTrust' && operation.line instanceof Asset) { + expect(operation.line.equals(new Asset('COOP', assetIssuer))).toBe(true); + expect(operation.limit).toBe('922337203685.4775807'); + } + }); + + it('builds an unsigned trustline establishment XDR with a custom limit', async () => { + const loadAccount = jest.fn().mockResolvedValue(new Account(accountPublicKey, '5')); + setMockServer({ loadAccount }); + + const response = await request(app).post('/api/v1/trustlines/build').send({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + limit: '500.25', + }); + + expect(response.status).toBe(200); + const transaction = TransactionBuilder.fromXDR( + response.body.data.xdr, + Networks.TESTNET + ) as Transaction; + const operation = transaction.operations[0]; + expect(operation.type).toBe('changeTrust'); + if (operation.type === 'changeTrust') { + expect(operation.limit).toBe('500.2500000'); + } + }); + + it('returns envelope-formatted Zod validation errors without calling Horizon', async () => { + const loadAccount = jest.fn(); + setMockServer({ loadAccount }); + + const response = await request(app).post('/api/v1/trustlines/build').send({ + accountPublicKey: 'invalid-key', + assetCode: '', + assetIssuer: 'also-invalid', + limit: '-10', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + data: null, + meta: { + errors: expect.arrayContaining([ + expect.objectContaining({ path: 'accountPublicKey' }), + expect.objectContaining({ path: 'assetCode' }), + expect.objectContaining({ path: 'assetIssuer' }), + expect.objectContaining({ path: 'limit' }), + ]), + }, + error: 'Validation failed', + }); + expect(loadAccount).not.toHaveBeenCalled(); + }); + + it('maps a missing source account from Horizon to an actionable response', async () => { + setMockServer({ + loadAccount: jest.fn().mockRejectedValue({ response: { status: 404 } }), + }); + + const response = await request(app).post('/api/v1/trustlines/build').send({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + }); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + data: null, + error: 'Stellar account or asset not found.', + }); + }); + + it('retries temporary Horizon failures before building with the current sequence', async () => { + const setTimeoutSpy = runTimeoutsImmediately(); + const loadAccount = jest + .fn() + .mockRejectedValueOnce({ response: { status: 503 } }) + .mockRejectedValueOnce({ response: { status: 503 } }) + .mockResolvedValueOnce(new Account(accountPublicKey, '42')); + setMockServer({ loadAccount }); + + const response = await request(app).post('/api/v1/trustlines/build').send({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + }); + + expect(response.status).toBe(200); + expect(loadAccount).toHaveBeenCalledTimes(3); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 100); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 200); + }); + + it('maps Horizon service failures without surfacing raw response objects', async () => { + setMockServer({ + loadAccount: jest.fn().mockRejectedValue({ + response: { status: 500, data: { detail: 'upstream request failed' } }, + }), + }); + + const response = await request(app).post('/api/v1/trustlines/build').send({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + }); + + expect(response.status).toBe(502); + expect(response.body).toEqual({ + data: null, + error: 'Stellar network error: upstream request failed', + }); + }); +}); diff --git a/backend/src/api/routes/__tests__/webhooks.test.ts b/backend/src/api/routes/__tests__/webhooks.test.ts new file mode 100644 index 0000000..3b1c798 --- /dev/null +++ b/backend/src/api/routes/__tests__/webhooks.test.ts @@ -0,0 +1,105 @@ +import request from 'supertest'; +import { createHmac } from 'crypto'; + +jest.mock('../../../db', () => ({ + db: { + query: jest.fn(), + ping: jest.fn().mockResolvedValue(true), + }, +})); + +jest.mock('../../../utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, +})); + +import app from '../../../app'; +import { STELLAR_WEBHOOK_SIGNATURE_HEADER } from '../../middleware/webhookSignature'; + +const SECRET = 'integration-test-secret'; + +function sign(payload: object): string { + return createHmac('sha256', SECRET).update(JSON.stringify(payload)).digest('hex'); +} + +describe('POST /api/v1/webhooks/stellar', () => { + const originalSecret = process.env.STELLAR_WEBHOOK_SECRET; + const validPayload = { + eventId: 'evt_12345', + eventType: 'payment.received', + occurredAt: '2026-01-01T00:00:00.000Z', + data: { amount: '10.0000000', assetCode: 'ECO' }, + }; + + beforeEach(() => { + process.env.STELLAR_WEBHOOK_SECRET = SECRET; + }); + + afterAll(() => { + process.env.STELLAR_WEBHOOK_SECRET = originalSecret; + }); + + it('accepts a validly signed, well-formed webhook payload', async () => { + const response = await request(app) + .post('/api/v1/webhooks/stellar') + .set(STELLAR_WEBHOOK_SIGNATURE_HEADER, sign(validPayload)) + .send(validPayload); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ data: { received: true, eventId: 'evt_12345' } }); + }); + + it('rejects a request with no signature header', async () => { + const response = await request(app).post('/api/v1/webhooks/stellar').send(validPayload); + + expect(response.status).toBe(401); + expect(response.body.data).toBeNull(); + }); + + it('rejects a request with an incorrect signature', async () => { + const response = await request(app) + .post('/api/v1/webhooks/stellar') + .set(STELLAR_WEBHOOK_SIGNATURE_HEADER, 'a'.repeat(64)) + .send(validPayload); + + expect(response.status).toBe(401); + }); + + it('rejects a correctly-signed but malformed payload with 400 before touching the DB', async () => { + const malformedPayload = { eventId: '', eventType: 'not.a.real.event', data: {} }; + const response = await request(app) + .post('/api/v1/webhooks/stellar') + .set(STELLAR_WEBHOOK_SIGNATURE_HEADER, sign(malformedPayload)) + .send(malformedPayload); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Validation failed'); + expect(response.body.meta.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'eventId' }), + expect.objectContaining({ path: 'eventType' }), + expect.objectContaining({ path: 'occurredAt' }), + ]) + ); + }); + + it('validates signature before payload shape, so a bad signature wins over a malformed body', async () => { + const malformedPayload = { eventId: '' }; + const response = await request(app) + .post('/api/v1/webhooks/stellar') + .set(STELLAR_WEBHOOK_SIGNATURE_HEADER, 'a'.repeat(64)) + .send(malformedPayload); + + expect(response.status).toBe(401); + }); + + it('fails closed with 503 when the server has no webhook secret configured', async () => { + delete process.env.STELLAR_WEBHOOK_SECRET; + + const response = await request(app) + .post('/api/v1/webhooks/stellar') + .set(STELLAR_WEBHOOK_SIGNATURE_HEADER, 'a'.repeat(64)) + .send(validPayload); + + expect(response.status).toBe(503); + }); +}); diff --git a/backend/src/api/routes/accounts.ts b/backend/src/api/routes/accounts.ts new file mode 100644 index 0000000..52d6b11 --- /dev/null +++ b/backend/src/api/routes/accounts.ts @@ -0,0 +1,47 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { StellarService } from '../../contracts/stellar'; +import { accountParamsSchema } from '../schemas/account'; +import { mapHorizonError } from '../utils/horizonError'; +import { formatAccountDetails } from '../utils/stellarAccount'; + +export const accountsRouter = Router(); + +/** + * GET /api/v1/accounts/:publicKey + * Returns full Stellar account details from Horizon for the given public key. + */ +accountsRouter.get( + '/:publicKey', + async (req: Request, res: Response, next: NextFunction): Promise => { + const parsedParams = accountParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + res.status(400).json({ + data: null, + error: 'Validation failed', + meta: { + errors: parsedParams.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + }); + return; + } + + try { + const { publicKey } = parsedParams.data; + const account = await StellarService.loadAccount(publicKey); + const accountDetails = formatAccountDetails(account); + + res.status(200).json({ data: accountDetails }); + } catch (err) { + if ((err as { response?: unknown }).response) { + const mapped = mapHorizonError(err); + res.status(mapped.status).json({ data: null, error: mapped.message }); + return; + } + + next(err); + } + } +); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 71ae57a..b2e9d74 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -4,6 +4,10 @@ import { tokenRouter } from './tokens'; import { balanceRouter } from './balances'; import { loanRouter } from './loans'; import { transactionRouter } from './transactions'; +import { webhookRouter } from './webhooks'; +import { pricesRouter } from './prices'; +import { trustlineRouter } from './trustlines'; +import { accountsRouter } from './accounts'; /** * Combined API router. Mounted under the `/api/v1` version prefix in app.ts so @@ -17,3 +21,7 @@ apiRouter.use('/tokens', tokenRouter); apiRouter.use('/balances', balanceRouter); apiRouter.use('/loans', loanRouter); apiRouter.use('/transactions', transactionRouter); +apiRouter.use('/webhooks', webhookRouter); +apiRouter.use('/prices', pricesRouter); +apiRouter.use('/trustlines', trustlineRouter); +apiRouter.use('/accounts', accountsRouter); diff --git a/backend/src/api/routes/prices.ts b/backend/src/api/routes/prices.ts new file mode 100644 index 0000000..a66bd09 --- /dev/null +++ b/backend/src/api/routes/prices.ts @@ -0,0 +1,51 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { getXlmPriceQuerySchema } from '../schemas/price'; +import { fetchXlmPrice } from '../../contracts/prices'; +import { cachePrice, getCachedPrice } from '../../cache/prices'; + +export const pricesRouter = Router(); + +/** + * GET /api/v1/prices/xlm + * Returns the current XLM price (e.g. XLM/USD) from a public market data provider. + * Successful responses are cached in Redis for up to 30 seconds. + */ +pricesRouter.get('/xlm', async (req: Request, res: Response, next: NextFunction): Promise => { + const parsedQuery = getXlmPriceQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + res.status(400).json({ + data: null, + error: 'Validation failed', + meta: { + errors: parsedQuery.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + }); + return; + } + + try { + const { currency } = parsedQuery.data; + const cached = await getCachedPrice('XLM', currency); + if (cached) { + res.status(200).json({ data: cached }); + return; + } + + const priceData = await fetchXlmPrice(currency); + await cachePrice('XLM', currency, priceData); + + res.status(200).json({ data: priceData }); + } catch (err) { + if (err instanceof Error && err.message.includes('public source')) { + res.status(502).json({ + data: null, + error: 'Failed to fetch price from public source.', + }); + return; + } + next(err); + } +}); diff --git a/backend/src/api/routes/tokens.ts b/backend/src/api/routes/tokens.ts index 86aa8be..e7f4e99 100644 --- a/backend/src/api/routes/tokens.ts +++ b/backend/src/api/routes/tokens.ts @@ -11,6 +11,7 @@ import { idempotent } from '../middleware/idempotency'; import { issueTokenSchema, trustlineTokenSchema, burnTokenSchema } from '../schemas/token'; import { isValidStellarPublicKey } from '../utils/stellar'; import { mapHorizonError } from '../utils/horizonError'; +import { withSequenceRetry } from '../../contracts/sequenceCache'; import { getNativeBalance, getRequiredXlmForFee, @@ -350,25 +351,34 @@ tokenRouter.post('/airdrop', async (req: Request, res: Response): Promise const asset = new Asset(community.asset_code, community.asset_issuer); const txHashes: string[] = []; + currentBalance = getNativeBalance(await StellarService.loadAccount(issuer.publicKey())); + + // Sequence numbers are handed out from an in-memory, per-account cache + // (see contracts/sequenceCache.ts) instead of reloading the issuer + // account from Horizon before every payment. This keeps a burst of + // airdrop payments — or a concurrent request touching the same issuer + // account — from racing on the same stale sequence number and getting + // rejected with tx_bad_seq. for (const member of members) { - const account = await StellarService.loadAccount(issuer.publicKey()); - currentBalance = getNativeBalance(account); - const transaction = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: network, - }) - .addOperation( - Operation.payment({ - destination: member.stellar_address, - asset, - amount, - }) - ) - .setTimeout(30) - .build(); - - transaction.sign(issuer); - const result = await StellarService.submitTransaction(transaction); + const result = await withSequenceRetry(issuer.publicKey(), async (account) => { + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: network, + }) + .addOperation( + Operation.payment({ + destination: member.stellar_address, + asset, + amount, + }) + ) + .setTimeout(30) + .build(); + + transaction.sign(issuer); + return StellarService.submitTransaction(transaction); + }); + txHashes.push(result.hash); } diff --git a/backend/src/api/routes/trustlines.ts b/backend/src/api/routes/trustlines.ts new file mode 100644 index 0000000..0604a2b --- /dev/null +++ b/backend/src/api/routes/trustlines.ts @@ -0,0 +1,33 @@ +import { Request, Response, Router } from 'express'; +import { buildUnsignedTrustline } from '../../contracts/trustlines'; +import { buildTrustlineSchema } from '../schemas/trustline'; +import { mapHorizonError } from '../utils/horizonError'; + +export const trustlineRouter = Router(); + +/** Build a changeTrust transaction for signing by the account's wallet. */ +trustlineRouter.post('/build', async (req: Request, res: Response): Promise => { + const parsed = buildTrustlineSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + data: null, + meta: { + errors: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + error: 'Validation failed', + }); + return; + } + + try { + const xdr = await buildUnsignedTrustline(parsed.data); + res.status(200).json({ data: { xdr } }); + } catch (error) { + const mapped = mapHorizonError(error); + res.status(mapped.status).json({ data: null, error: mapped.message }); + } +}); diff --git a/backend/src/api/routes/webhooks.ts b/backend/src/api/routes/webhooks.ts new file mode 100644 index 0000000..5d99bef --- /dev/null +++ b/backend/src/api/routes/webhooks.ts @@ -0,0 +1,28 @@ +import { Router, Request, Response } from 'express'; +import { verifyWebhookSignature } from '../middleware/webhookSignature'; +import { validateBody } from '../middleware/validate'; +import { stellarWebhookSchema } from '../schemas/webhook'; +import { logger } from '../../utils/logger'; + +export const webhookRouter = Router(); + +/** + * POST /api/v1/webhooks/stellar + * Receives Stellar account/transaction event notifications from a trusted + * webhook source. The request must carry a valid HMAC-SHA256 signature (see + * `verifyWebhookSignature`) computed over the raw request body with + * `STELLAR_WEBHOOK_SECRET`; requests without one are rejected before the + * body is even validated. + */ +webhookRouter.post( + '/stellar', + verifyWebhookSignature, + validateBody(stellarWebhookSchema), + (req: Request, res: Response): void => { + const { eventId, eventType } = req.body as { eventId: string; eventType: string }; + + logger.info('Received Stellar webhook event', { eventId, eventType }); + + res.status(200).json({ data: { received: true, eventId } }); + } +); diff --git a/backend/src/api/schemas/account.ts b/backend/src/api/schemas/account.ts new file mode 100644 index 0000000..5638f35 --- /dev/null +++ b/backend/src/api/schemas/account.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { isValidStellarPublicKey } from '../utils/stellar'; + +export const accountParamsSchema = z.object({ + publicKey: z + .string() + .trim() + .refine(isValidStellarPublicKey, { message: 'publicKey must be a valid Stellar public key' }), +}); + +export type AccountParamsInput = z.infer; diff --git a/backend/src/api/schemas/price.ts b/backend/src/api/schemas/price.ts new file mode 100644 index 0000000..9541f1b --- /dev/null +++ b/backend/src/api/schemas/price.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const getXlmPriceQuerySchema = z.object({ + currency: z + .string() + .trim() + .toUpperCase() + .regex(/^[A-Z]{3,4}$/, { message: 'currency must be a valid 3- or 4-letter currency code' }) + .optional() + .default('USD'), +}); + +export type GetXlmPriceQueryInput = z.infer; diff --git a/backend/src/api/schemas/trustline.ts b/backend/src/api/schemas/trustline.ts new file mode 100644 index 0000000..d8b9977 --- /dev/null +++ b/backend/src/api/schemas/trustline.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { isValidStellarPublicKey } from '../utils/stellar'; + +const stellarPublicKey = z + .string() + .trim() + .refine(isValidStellarPublicKey, 'must be a valid Stellar public key'); + +const limitAmount = z + .string() + .trim() + .regex(/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/, 'limit must be a positive decimal string') + .refine((value) => Number(value) > 0, 'limit must be greater than zero'); + +export const buildTrustlineSchema = z.object({ + accountPublicKey: stellarPublicKey, + assetCode: z + .string() + .trim() + .min(1, 'assetCode is required') + .max(12, 'assetCode must be 12 characters or fewer') + .regex(/^[A-Za-z0-9]+$/, 'assetCode must be alphanumeric'), + assetIssuer: stellarPublicKey, + limit: limitAmount.optional(), +}); + +export type BuildTrustlineInput = z.infer; diff --git a/backend/src/api/schemas/webhook.ts b/backend/src/api/schemas/webhook.ts new file mode 100644 index 0000000..3122aea --- /dev/null +++ b/backend/src/api/schemas/webhook.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * Payload shape for incoming Stellar webhook notifications (e.g. from a + * Horizon-event forwarder or a custom notification relay watching an + * account). `data` is intentionally loose — event-specific fields vary by + * `eventType` and are not re-validated here. + */ +export const stellarWebhookSchema = z.object({ + eventId: z.string().trim().min(1, 'eventId is required'), + eventType: z.enum([ + 'transaction.succeeded', + 'transaction.failed', + 'account.created', + 'trustline.created', + 'trustline.removed', + 'payment.received', + ]), + occurredAt: z.string().datetime({ message: 'occurredAt must be an ISO 8601 timestamp' }), + data: z.record(z.string(), z.unknown()), +}); + +export type StellarWebhookPayload = z.infer; diff --git a/backend/src/api/utils/__tests__/horizonError.test.ts b/backend/src/api/utils/__tests__/horizonError.test.ts new file mode 100644 index 0000000..c050b3e --- /dev/null +++ b/backend/src/api/utils/__tests__/horizonError.test.ts @@ -0,0 +1,167 @@ +import { mapHorizonError } from '../horizonError'; + +function horizonError(opts: { + status?: number; + transaction?: string; + operations?: string[]; + title?: string; + detail?: string; +}): { + response: { + status?: number; + data: { + extras: { result_codes: { transaction?: string; operations?: string[] } }; + title?: string; + detail?: string; + }; + }; +} { + return { + response: { + status: opts.status, + data: { + extras: { + result_codes: { + transaction: opts.transaction, + operations: opts.operations, + }, + }, + title: opts.title, + detail: opts.detail, + }, + }, + }; +} + +describe('mapHorizonError', () => { + describe('transaction-level result codes', () => { + const cases: Array<[string, number]> = [ + ['tx_too_early', 422], + ['tx_too_late', 422], + ['tx_missing_operation', 422], + ['tx_bad_seq', 422], + ['tx_bad_auth', 422], + ['tx_no_source_account', 422], + ['tx_insufficient_fee', 422], + ['tx_bad_auth_extra', 422], + ['tx_internal_error', 422], + ['tx_not_supported', 422], + ['tx_fee_bump_inner_failed', 422], + ['tx_bad_sponsorship', 422], + ['tx_bad_min_seq_age_or_gap', 422], + ['tx_malformed', 422], + ]; + + it.each(cases)('maps %s to status %d with a friendly message', (code, status) => { + const mapped = mapHorizonError(horizonError({ transaction: code })); + expect(mapped.status).toBe(status); + expect(mapped.message).not.toBe(''); + expect(mapped.message).not.toMatch(/^tx_/); + }); + + it('maps tx_insufficient_balance to 402 with the INSUFFICIENT_BALANCE code and details', () => { + const mapped = mapHorizonError(horizonError({ transaction: 'tx_insufficient_balance' }), { + requiredXlm: '10.0000000', + currentBalance: '2.0000000', + }); + + expect(mapped.status).toBe(402); + expect(mapped.code).toBe('INSUFFICIENT_BALANCE'); + expect(mapped.requiredXlm).toBe('10.0000000'); + expect(mapped.currentBalance).toBe('2.0000000'); + }); + }); + + describe('operation-level result codes', () => { + const cases: string[] = [ + 'op_bad_auth', + 'op_no_account', + 'op_not_supported', + 'op_too_many_subentries', + 'op_exceeded_work_limit', + 'op_too_many_sponsoring', + 'op_malformed', + 'op_underfunded', + 'op_no_trust', + 'op_line_full', + 'op_no_destination', + 'op_no_issuer', + 'op_src_no_trust', + 'op_src_not_authorized', + 'op_not_authorized', + 'op_cross_self', + 'op_too_few_offers', + 'op_offer_cross_self', + 'op_invalid_limit', + 'op_low_reserve', + 'op_trust_not_required', + 'op_cant_delete', + 'op_trust_line_missing', + 'op_is_authorized', + 'op_deauthorize_not_allowed', + 'op_already_exists', + 'op_immutable_set', + 'op_has_sub_entries', + 'op_seqnum_too_far', + 'op_dest_full', + 'op_too_many_signers', + 'op_bad_signer', + 'op_invalid_home_domain', + 'op_auth_revocable_required', + 'op_sell_no_trust', + 'op_buy_no_trust', + 'op_sell_not_authorized', + 'op_buy_not_authorized', + 'op_offer_not_found', + 'op_not_found', + 'op_cannot_claim', + 'op_claimant_count_exceeds_limit', + ]; + + it.each(cases)('maps %s to a 422 with a friendly message', (code) => { + const mapped = mapHorizonError(horizonError({ operations: [code] })); + expect(mapped.status).toBe(422); + expect(mapped.message).not.toBe(''); + expect(mapped.message).not.toMatch(/^op_/); + }); + + it('prefers the operation code over the transaction code when both are present', () => { + const mapped = mapHorizonError( + horizonError({ transaction: 'tx_failed', operations: ['op_underfunded'] }) + ); + expect(mapped.message).toBe('Insufficient balance to complete this operation.'); + }); + }); + + describe('fallbacks', () => { + it('maps a 404 Horizon response to a 404 with a friendly message', () => { + const mapped = mapHorizonError(horizonError({ status: 404 })); + expect(mapped.status).toBe(404); + expect(mapped.message).toBe('Stellar account or asset not found.'); + }); + + it('falls back to a 502 including the Horizon detail when no result code matches', () => { + const mapped = mapHorizonError(horizonError({ detail: 'Horizon is down for maintenance' })); + expect(mapped.status).toBe(502); + expect(mapped.message).toBe('Stellar network error: Horizon is down for maintenance'); + }); + + it('falls back to the Horizon title when detail is absent', () => { + const mapped = mapHorizonError(horizonError({ title: 'Bad Request' })); + expect(mapped.status).toBe(502); + expect(mapped.message).toBe('Stellar network error: Bad Request'); + }); + + it('falls back to a generic message for a completely unrecognized error', () => { + const mapped = mapHorizonError(new Error('network timeout')); + expect(mapped.status).toBe(502); + expect(mapped.message).toBe('Stellar network error. Please try again later.'); + }); + + it('falls back to a generic message for an unknown result code', () => { + const mapped = mapHorizonError(horizonError({ transaction: 'tx_some_future_code' })); + expect(mapped.status).toBe(502); + expect(mapped.message).toBe('Stellar network error. Please try again later.'); + }); + }); +}); diff --git a/backend/src/api/utils/horizonError.ts b/backend/src/api/utils/horizonError.ts index 8fc644a..7a3d1ff 100644 --- a/backend/src/api/utils/horizonError.ts +++ b/backend/src/api/utils/horizonError.ts @@ -22,19 +22,87 @@ export interface InsufficientBalanceDetails { currentBalance?: string; } +/** + * Operation-level result codes shared across Stellar's payment, trustline, + * account-creation, and authorization operations. + * https://developers.stellar.org/docs/data/horizon/api-reference/errors/result-codes/operation-specific + */ const OPERATION_MESSAGES: Record = { + // Common to most operation types + op_bad_auth: 'Transaction is missing a required signature for this operation.', + op_no_account: 'The source account for this operation does not exist.', + op_not_supported: 'This operation is not supported by the network.', + op_too_many_subentries: 'Account has reached the maximum number of subentries (trustlines, offers, signers).', + op_exceeded_work_limit: 'Operation was rejected because it exceeded the allowed processing limit.', + op_too_many_sponsoring: 'Account is sponsoring too many reserves to complete this operation.', + op_malformed: 'Operation parameters are malformed.', + + // Payment / path payment op_underfunded: 'Insufficient balance to complete this operation.', op_no_trust: 'Destination account does not have a trustline for this asset.', op_line_full: "Destination account's trustline limit would be exceeded.", op_no_destination: 'Destination account does not exist.', + op_no_issuer: 'The asset issuer account does not exist.', op_src_no_trust: 'Source account does not have a trustline for this asset.', + op_src_not_authorized: 'Source account is not authorized to transfer this asset.', op_not_authorized: 'Account is not authorized to hold or transfer this asset.', + op_cross_self: 'The payment path crosses its own offer.', + op_too_few_offers: 'No path could be found to complete this payment.', + op_offer_cross_self: 'The payment path crosses its own offer.', + + // Change trust + op_invalid_limit: 'Trustline limit must be greater than the current balance.', + op_low_reserve: 'Account does not hold enough XLM to cover the minimum reserve for this operation.', + op_trust_not_required: 'A trustline is not required for this asset (it is issued by this account).', + op_cant_delete: 'Trustline cannot be removed while it still holds a balance or has open offers.', + op_trust_line_missing: 'No trustline exists for this asset.', + op_is_authorized: 'Trustline is already authorized.', + op_deauthorize_not_allowed: 'The asset issuer does not allow trustline deauthorization.', + + // Create account + op_already_exists: 'An account already exists at this address.', + + // Account merge / signers / set options + op_immutable_set: 'Account settings are immutable and cannot be changed.', + op_has_sub_entries: 'Account cannot be merged while it still holds trustlines, offers, or data entries.', + op_seqnum_too_far: "Account's sequence number is too far in the future to merge.", + op_dest_full: 'Destination account has reached the maximum XLM balance it can hold.', + op_too_many_signers: 'Account has reached the maximum number of signers.', + op_bad_signer: 'The signer key or weight provided is invalid.', + op_invalid_home_domain: 'The home domain value is invalid.', + op_auth_revocable_required: "This operation requires the issuer's AUTH_REVOCABLE flag to be set.", + + // Manage offer / claimable balance + op_sell_no_trust: 'Selling asset requires a trustline that does not exist.', + op_buy_no_trust: 'Buying asset requires a trustline that does not exist.', + op_sell_not_authorized: 'Account is not authorized to sell this asset.', + op_buy_not_authorized: 'Account is not authorized to buy this asset.', + op_offer_not_found: 'The referenced offer does not exist.', + op_not_found: 'The referenced claimable balance does not exist.', + op_cannot_claim: 'This account is not permitted to claim this balance.', + op_claimant_count_exceeds_limit: 'Too many claimants specified for this claimable balance.', }; +/** + * Transaction-level result codes. + * https://developers.stellar.org/docs/data/horizon/api-reference/errors/result-codes/transactions + */ const TRANSACTION_MESSAGES: Record = { + tx_too_early: 'Transaction submitted before its valid start time.', + tx_too_late: 'Transaction submitted after its valid end time; please rebuild and resubmit.', + tx_missing_operation: 'Transaction must contain at least one operation.', tx_bad_seq: 'Transaction sequence number is stale; please retry.', + tx_bad_auth: 'Transaction is missing a valid signature for the source account.', tx_insufficient_balance: 'Account balance is insufficient to cover the transaction and fees.', + tx_no_source_account: 'The source account for this transaction does not exist.', tx_insufficient_fee: 'Submitted fee is below the network minimum.', + tx_bad_auth_extra: 'Transaction has unused or extraneous signatures.', + tx_internal_error: 'The Stellar network encountered an internal error processing this transaction.', + tx_not_supported: 'This transaction type is not supported by the network.', + tx_fee_bump_inner_failed: 'The inner transaction of this fee-bump transaction failed.', + tx_bad_sponsorship: 'Reserve sponsorship in this transaction is malformed.', + tx_bad_min_seq_age_or_gap: 'Transaction does not satisfy the minimum sequence age or ledger gap.', + tx_malformed: 'Transaction envelope is malformed.', }; /** Maps a Horizon/Stellar SDK submission error to a clear, actionable message. */ diff --git a/backend/src/api/utils/stellarAccount.ts b/backend/src/api/utils/stellarAccount.ts new file mode 100644 index 0000000..45c943a --- /dev/null +++ b/backend/src/api/utils/stellarAccount.ts @@ -0,0 +1,87 @@ +import { HorizonApi } from '@stellar/stellar-sdk/lib/horizon/horizon_api'; +import { ServerApi } from '@stellar/stellar-sdk/lib/horizon/server_api'; + +export interface FormattedAccountDetails { + id: string; + account_id: string; + sequence: string; + sequence_ledger?: number; + sequence_time?: string; + subentry_count: number; + inflation_destination?: string; + home_domain?: string; + last_modified_ledger: number; + last_modified_time?: string; + thresholds: HorizonApi.AccountThresholds; + flags: HorizonApi.Flags; + balances: HorizonApi.BalanceLine[]; + signers: ServerApi.AccountRecordSigners[]; + data: Record; + num_sponsoring?: number; + num_sponsored?: number; + sponsor?: string; + paging_token?: string; +} + +/** Formats an AccountResponse from Horizon into clean account details. */ +export function formatAccountDetails(account: unknown): FormattedAccountDetails { + const acc = account as { + id?: string; + account_id?: string; + sequence?: string; + sequenceNumber?: () => string; + sequence_ledger?: number; + sequence_time?: string; + subentry_count?: number; + inflation_destination?: string; + home_domain?: string; + last_modified_ledger?: number; + last_modified_time?: string; + thresholds?: HorizonApi.AccountThresholds; + flags?: HorizonApi.Flags; + balances?: HorizonApi.BalanceLine[]; + signers?: ServerApi.AccountRecordSigners[]; + data_attr?: Record; + data?: unknown; + num_sponsoring?: number; + num_sponsored?: number; + sponsor?: string; + paging_token?: string; + }; + + const id = acc.id ?? acc.account_id ?? ''; + const dataMap = + acc.data_attr ?? + (typeof acc.data === 'object' && acc.data !== null && !Array.isArray(acc.data) + ? (acc.data as Record) + : {}); + + return { + id, + account_id: acc.account_id ?? id, + sequence: String(acc.sequence ?? acc.sequenceNumber?.() ?? '0'), + ...(acc.sequence_ledger !== undefined && { sequence_ledger: acc.sequence_ledger }), + ...(acc.sequence_time !== undefined && { sequence_time: acc.sequence_time }), + subentry_count: acc.subentry_count ?? 0, + ...(acc.inflation_destination !== undefined && { + inflation_destination: acc.inflation_destination, + }), + ...(acc.home_domain !== undefined && { home_domain: acc.home_domain }), + last_modified_ledger: acc.last_modified_ledger ?? 0, + ...(acc.last_modified_time !== undefined && { last_modified_time: acc.last_modified_time }), + thresholds: acc.thresholds ?? { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, + flags: acc.flags ?? { + auth_required: false, + auth_revocable: false, + auth_immutable: false, + auth_clawback_enabled: false, + }, + balances: acc.balances ?? [], + signers: acc.signers ?? [], + data: dataMap, + ...(acc.num_sponsoring !== undefined && { num_sponsoring: acc.num_sponsoring }), + ...(acc.num_sponsored !== undefined && { num_sponsored: acc.num_sponsored }), + ...(acc.sponsor !== undefined && { sponsor: acc.sponsor }), + ...(acc.paging_token !== undefined && { paging_token: acc.paging_token }), + }; +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 5c353e0..75adc9d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,7 +13,16 @@ const app = express(); app.use(helmet()); app.use(cors({ origin: process.env.FRONTEND_URL ?? 'http://localhost:3000' })); -app.use(express.json()); +// Captures the exact bytes received so webhook signature verification can +// HMAC over the same payload the client signed, before JSON parsing/ +// re-serialization has a chance to change its byte representation. +app.use( + express.json({ + verify: (req: Request & { rawBody?: Buffer }, _res, buf) => { + req.rawBody = Buffer.from(buf); + }, + }) +); app.use(requestLogger); const healthHandler = (_req: Request, res: Response, next: NextFunction): void => { diff --git a/backend/src/cache/prices.ts b/backend/src/cache/prices.ts new file mode 100644 index 0000000..9236621 --- /dev/null +++ b/backend/src/cache/prices.ts @@ -0,0 +1,47 @@ +import { redisCache } from './redis'; +import { logger } from '../utils/logger'; +import { XlmPriceData } from '../contracts/prices'; + +export const PRICE_CACHE_TTL_SECONDS = 30; + +export function getPriceCacheKey(asset: string, currency: string): string { + return `prices:${asset.toUpperCase()}:${currency.toUpperCase()}`; +} + +export async function getCachedPrice( + asset: string, + currency: string +): Promise { + const key = getPriceCacheKey(asset, currency); + const cached = await redisCache.get(key); + + if (!cached) return null; + + try { + const parsed = JSON.parse(cached) as XlmPriceData; + if (parsed && typeof parsed.price === 'string' && typeof parsed.currency === 'string') { + return parsed; + } + await redisCache.del(key); + return null; + } catch (error) { + logger.warn('Discarding malformed cached price payload', { + key, + error: error instanceof Error ? error.message : String(error), + }); + await redisCache.del(key); + return null; + } +} + +export async function cachePrice( + asset: string, + currency: string, + priceData: XlmPriceData +): Promise { + await redisCache.setEx( + getPriceCacheKey(asset, currency), + PRICE_CACHE_TTL_SECONDS, + JSON.stringify(priceData) + ); +} diff --git a/backend/src/contracts/__tests__/sequenceCache.test.ts b/backend/src/contracts/__tests__/sequenceCache.test.ts new file mode 100644 index 0000000..a4c3418 --- /dev/null +++ b/backend/src/contracts/__tests__/sequenceCache.test.ts @@ -0,0 +1,218 @@ +import { Account, Keypair } from '@stellar/stellar-sdk'; +import { SequenceCache, isBadSequenceError, withSequenceRetry } from '../sequenceCache'; +import { StellarService } from '../stellar'; + +jest.mock('../stellar', () => ({ + StellarService: { + loadAccount: jest.fn(), + }, +})); + +jest.mock('../../utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, +})); + +const mockLoadAccount = StellarService.loadAccount as jest.Mock; +const publicKey = Keypair.random().publicKey(); + +function horizonAccount(sequence: string): { sequenceNumber: () => string } { + return { sequenceNumber: () => sequence }; +} + +function badSeqError(): { + response: { data: { extras: { result_codes: { transaction: string } } } }; +} { + return { + response: { data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } } }, + }; +} + +describe('SequenceCache', () => { + beforeEach(() => { + mockLoadAccount.mockReset(); + (SequenceCache as unknown as { cache: Map }).cache.clear(); + (SequenceCache as unknown as { queues: Map }).queues.clear(); + }); + + it('loads the account from Horizon once and reuses the cached instance', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + + const first = await SequenceCache.withAccount(publicKey, async (account) => + account.sequenceNumber() + ); + const second = await SequenceCache.withAccount(publicKey, async (account) => + account.sequenceNumber() + ); + + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + expect(first).toBe('100'); + expect(second).toBe('100'); + }); + + it('reflects the sequence increment made by a previous caller (as TransactionBuilder would)', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + + await SequenceCache.withAccount(publicKey, async (account) => { + account.incrementSequenceNumber(); + }); + const sequenceAfter = await SequenceCache.withAccount(publicKey, async (account) => + account.sequenceNumber() + ); + + expect(sequenceAfter).toBe('101'); + }); + + it('serializes concurrent callers for the same account instead of racing on the same sequence', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + const observedSequences: string[] = []; + + const call = (): Promise => + SequenceCache.withAccount(publicKey, async (account) => { + observedSequences.push(account.sequenceNumber()); + // Simulate the async gap between reading the sequence and Horizon + // accepting the built transaction. + await new Promise((resolve) => setTimeout(resolve, 5)); + account.incrementSequenceNumber(); + }); + + await Promise.all([call(), call(), call()]); + + expect(observedSequences.sort()).toEqual(['100', '101', '102']); + // Concurrent callers share one cached account load. + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + }); + + it('keeps each account isolated from other accounts', async () => { + const otherKey = Keypair.random().publicKey(); + mockLoadAccount.mockImplementation((key: string) => + Promise.resolve(horizonAccount(key === publicKey ? '100' : '5')) + ); + + const [a, b] = await Promise.all([ + SequenceCache.withAccount(publicKey, async (account) => account.sequenceNumber()), + SequenceCache.withAccount(otherKey, async (account) => account.sequenceNumber()), + ]); + + expect(a).toBe('100'); + expect(b).toBe('5'); + }); + + it('propagates errors thrown inside the callback and still releases the queue', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + + await expect( + SequenceCache.withAccount(publicKey, async () => { + throw new Error('submission failed'); + }) + ).rejects.toThrow('submission failed'); + + // The queue was released, so a subsequent call proceeds normally. + const sequence = await SequenceCache.withAccount(publicKey, async (account) => + account.sequenceNumber() + ); + expect(sequence).toBe('100'); + }); + + it('invalidate() forces the next call to reload from Horizon', async () => { + mockLoadAccount.mockResolvedValueOnce(horizonAccount('100')); + await SequenceCache.withAccount(publicKey, async () => undefined); + + SequenceCache.invalidate(publicKey); + + mockLoadAccount.mockResolvedValueOnce(horizonAccount('200')); + const sequence = await SequenceCache.withAccount(publicKey, async (account) => + account.sequenceNumber() + ); + + expect(sequence).toBe('200'); + expect(mockLoadAccount).toHaveBeenCalledTimes(2); + }); +}); + +describe('isBadSequenceError', () => { + it('returns true for a tx_bad_seq Horizon error', () => { + expect(isBadSequenceError(badSeqError())).toBe(true); + }); + + it('returns false for other Horizon errors', () => { + expect( + isBadSequenceError({ + response: { data: { extras: { result_codes: { transaction: 'tx_insufficient_fee' } } } }, + }) + ).toBe(false); + }); + + it('returns false for a non-Horizon error', () => { + expect(isBadSequenceError(new Error('boom'))).toBe(false); + }); +}); + +describe('withSequenceRetry', () => { + beforeEach(() => { + mockLoadAccount.mockReset(); + (SequenceCache as unknown as { cache: Map }).cache.clear(); + (SequenceCache as unknown as { queues: Map }).queues.clear(); + }); + + it('returns the result on the first attempt when nothing fails', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + + const result = await withSequenceRetry(publicKey, async () => 'ok'); + + expect(result).toBe('ok'); + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + }); + + it('invalidates the cache and retries exactly once on tx_bad_seq', async () => { + mockLoadAccount.mockResolvedValueOnce(horizonAccount('100')); + mockLoadAccount.mockResolvedValueOnce(horizonAccount('101')); + + let attempt = 0; + const result = await withSequenceRetry(publicKey, async (account) => { + attempt += 1; + if (attempt === 1) { + throw badSeqError(); + } + return account.sequenceNumber(); + }); + + expect(result).toBe('101'); + expect(attempt).toBe(2); + expect(mockLoadAccount).toHaveBeenCalledTimes(2); + }); + + it('propagates a second tx_bad_seq without retrying again', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + + await expect( + withSequenceRetry(publicKey, async () => { + throw badSeqError(); + }) + ).rejects.toMatchObject({ + response: { data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } } }, + }); + + expect(mockLoadAccount).toHaveBeenCalledTimes(2); + }); + + it('propagates non-sequence errors without retrying', async () => { + mockLoadAccount.mockResolvedValue(horizonAccount('100')); + + await expect( + withSequenceRetry(publicKey, async () => { + throw new Error('insufficient balance'); + }) + ).rejects.toThrow('insufficient balance'); + + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + }); +}); + +// Sanity check that the real Account class behaves as assumed above. +describe('Account increment semantics (sdk sanity check)', () => { + it('increments sequence as a string-safe bigint', () => { + const account = new Account(publicKey, '9007199254740993'); + account.incrementSequenceNumber(); + expect(account.sequenceNumber()).toBe('9007199254740994'); + }); +}); diff --git a/backend/src/contracts/__tests__/stellar.test.ts b/backend/src/contracts/__tests__/stellar.test.ts new file mode 100644 index 0000000..5edfa75 --- /dev/null +++ b/backend/src/contracts/__tests__/stellar.test.ts @@ -0,0 +1,104 @@ +import { Horizon, Keypair, Networks } from '@stellar/stellar-sdk'; +import { StellarService, HORIZON_RETRY_CONFIG } from '../stellar'; + +describe('StellarService', () => { + const originalNetwork = StellarService.getNetwork(); + + afterEach(() => { + (StellarService as unknown as { network: string }).network = originalNetwork; + jest.restoreAllMocks(); + }); + + describe('isTestnet and isMainnet', () => { + it('correctly identifies Testnet network', () => { + (StellarService as unknown as { network: string }).network = Networks.TESTNET; + expect(StellarService.isTestnet()).toBe(true); + expect(StellarService.isMainnet()).toBe(false); + expect(StellarService.getNetwork()).toBe(Networks.TESTNET); + }); + + it('correctly identifies Mainnet network', () => { + (StellarService as unknown as { network: string }).network = Networks.PUBLIC; + expect(StellarService.isTestnet()).toBe(false); + expect(StellarService.isMainnet()).toBe(true); + expect(StellarService.getNetwork()).toBe(Networks.PUBLIC); + }); + + it('returns false for both if an unknown network passphrase is set', () => { + (StellarService as unknown as { network: string }).network = 'Custom Standalone Network'; + expect(StellarService.isTestnet()).toBe(false); + expect(StellarService.isMainnet()).toBe(false); + }); + }); + + describe('loadAccount and getAccountBalance', () => { + it('loads account from server and returns balances', async () => { + const publicKey = Keypair.random().publicKey(); + const mockBalances = [ + { asset_type: 'native', balance: '100.0000000' }, + ] as Horizon.HorizonApi.BalanceLine[]; + + const mockLoadAccount = jest.fn().mockResolvedValue({ + id: publicKey, + balances: mockBalances, + }); + + const server = StellarService.getServer(); + jest.spyOn(server, 'loadAccount').mockImplementation(mockLoadAccount); + + const balances = await StellarService.getAccountBalance(publicKey); + expect(mockLoadAccount).toHaveBeenCalledWith(publicKey); + expect(balances).toEqual(mockBalances); + }); + }); + + describe('retry logic', () => { + it('retries on 429 and 503 errors and succeeds', async () => { + const operationMock = jest + .fn() + .mockRejectedValueOnce({ response: { status: 429, headers: { 'retry-after': '0' } } }) + .mockRejectedValueOnce({ response: { status: 503 } }) + .mockResolvedValueOnce('success'); + + jest.spyOn(global, 'setTimeout').mockImplementation((( + callback: (...args: unknown[]) => void + ) => { + callback(); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + + const result = await StellarService.call('testOp', operationMock); + expect(result).toBe('success'); + expect(operationMock).toHaveBeenCalledTimes(3); + }); + + it('does not retry non-retryable errors (e.g. 404)', async () => { + const operationMock = jest + .fn() + .mockRejectedValue({ response: { status: 404 }, message: 'Not found' }); + + await expect(StellarService.call('testOp', operationMock)).rejects.toMatchObject({ + response: { status: 404 }, + }); + expect(operationMock).toHaveBeenCalledTimes(1); + }); + + it('throws error after exhausting maxAttempts on retryable status', async () => { + const operationMock = jest + .fn() + .mockRejectedValue({ response: { status: 503 }, message: 'Service Unavailable' }); + + jest.spyOn(global, 'setTimeout').mockImplementation((( + callback: (...args: unknown[]) => void + ) => { + callback(); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + + await expect(StellarService.call('testOp', operationMock)).rejects.toMatchObject({ + response: { status: 503 }, + }); + expect(operationMock).toHaveBeenCalledTimes(HORIZON_RETRY_CONFIG.maxAttempts); + }); + }); +}); diff --git a/backend/src/contracts/__tests__/trustlines.test.ts b/backend/src/contracts/__tests__/trustlines.test.ts index 66c2d5f..dacd0a2 100644 --- a/backend/src/contracts/__tests__/trustlines.test.ts +++ b/backend/src/contracts/__tests__/trustlines.test.ts @@ -1,4 +1,12 @@ -import { hasTrustline } from '../trustlines'; +import { + Account, + Asset, + Keypair, + Networks, + Transaction, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { buildUnsignedTrustline, hasTrustline } from '../trustlines'; import { StellarService } from '../stellar'; jest.mock('../stellar', () => ({ @@ -42,3 +50,57 @@ describe('hasTrustline', () => { expect(result).toBe(false); }); }); + +describe('buildUnsignedTrustline', () => { + const mockLoadAccount = StellarService.loadAccount as jest.Mock; + const accountPublicKey = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)).publicKey(); + const assetIssuer = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey(); + + beforeEach(() => { + mockLoadAccount.mockReset(); + (StellarService.getNetwork as jest.Mock).mockReturnValue(Networks.TESTNET); + }); + + it('builds an unsigned changeTrust transaction with the loaded sequence number', async () => { + mockLoadAccount.mockResolvedValueOnce(new Account(accountPublicKey, '50')); + + const xdr = await buildUnsignedTrustline({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + }); + + expect(mockLoadAccount).toHaveBeenCalledWith(accountPublicKey); + + const transaction = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + expect(transaction.sequence).toBe('51'); + expect(transaction.signatures).toHaveLength(0); + expect(transaction.operations).toHaveLength(1); + + const operation = transaction.operations[0]; + expect(operation.type).toBe('changeTrust'); + if (operation.type === 'changeTrust' && operation.line instanceof Asset) { + expect(operation.line.getCode()).toBe('COOP'); + expect(operation.line.getIssuer()).toBe(assetIssuer); + expect(operation.limit).toBe('922337203685.4775807'); + } + }); + + it('builds an unsigned changeTrust transaction with a custom limit', async () => { + mockLoadAccount.mockResolvedValueOnce(new Account(accountPublicKey, '10')); + + const xdr = await buildUnsignedTrustline({ + accountPublicKey, + assetCode: 'COOP', + assetIssuer, + limit: '5000.5', + }); + + const transaction = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + const operation = transaction.operations[0]; + expect(operation.type).toBe('changeTrust'); + if (operation.type === 'changeTrust') { + expect(operation.limit).toBe('5000.5000000'); + } + }); +}); diff --git a/backend/src/contracts/assets.ts b/backend/src/contracts/assets.ts index 00fc79a..e18e37d 100644 --- a/backend/src/contracts/assets.ts +++ b/backend/src/contracts/assets.ts @@ -8,6 +8,7 @@ import { } from '@stellar/stellar-sdk'; import { StellarService } from './stellar'; import { invalidateBalanceCache } from '../cache/balances'; +import { withSequenceRetry } from './sequenceCache'; export interface IssueAssetParams { issuerSecret: string; @@ -38,31 +39,32 @@ export async function issueAsset(params: IssueAssetParams): Promise { const issuerKeypair = Keypair.fromSecret(issuerSecret); const network = StellarService.getNetwork(); - - const issuerAccount = await StellarService.loadAccount(issuerKeypair.publicKey()); const asset = new Asset(assetCode, issuerKeypair.publicKey()); - const txBuilder = new TransactionBuilder(issuerAccount, { - fee: BASE_FEE, - networkPassphrase: network, - }); + const result = await withSequenceRetry(issuerKeypair.publicKey(), async (issuerAccount) => { + const txBuilder = new TransactionBuilder(issuerAccount, { + fee: BASE_FEE, + networkPassphrase: network, + }); - if (memo) { - txBuilder.addMemo(Memo.text(memo)); - } + if (memo) { + txBuilder.addMemo(Memo.text(memo)); + } - txBuilder.addOperation( - Operation.payment({ - destination: distributorPublicKey, - asset, - amount, - }) - ); + txBuilder.addOperation( + Operation.payment({ + destination: distributorPublicKey, + asset, + amount, + }) + ); - const tx = txBuilder.setTimeout(30).build(); - tx.sign(issuerKeypair); + const tx = txBuilder.setTimeout(30).build(); + tx.sign(issuerKeypair); + + return StellarService.submitTransaction(tx); + }); - const result = await StellarService.submitTransaction(tx); await invalidateBalanceCache([issuerKeypair.publicKey(), distributorPublicKey]); return result.hash; } @@ -77,27 +79,27 @@ export async function burnAsset(params: BurnAssetParams): Promise { const holderKeypair = Keypair.fromSecret(holderSecret); const network = StellarService.getNetwork(); - - const holderAccount = await StellarService.loadAccount(holderKeypair.publicKey()); const asset = new Asset(assetCode, assetIssuer); - const tx = new TransactionBuilder(holderAccount, { - fee: BASE_FEE, - networkPassphrase: network, - }) - .addOperation( - Operation.payment({ - destination: assetIssuer, - asset, - amount, - }) - ) - .setTimeout(30) - .build(); - - tx.sign(holderKeypair); + const result = await withSequenceRetry(holderKeypair.publicKey(), async (holderAccount) => { + const tx = new TransactionBuilder(holderAccount, { + fee: BASE_FEE, + networkPassphrase: network, + }) + .addOperation( + Operation.payment({ + destination: assetIssuer, + asset, + amount, + }) + ) + .setTimeout(30) + .build(); + + tx.sign(holderKeypair); + return StellarService.submitTransaction(tx); + }); - const result = await StellarService.submitTransaction(tx); await invalidateBalanceCache([holderKeypair.publicKey(), assetIssuer]); return result.hash; } diff --git a/backend/src/contracts/prices.ts b/backend/src/contracts/prices.ts new file mode 100644 index 0000000..f942f4c --- /dev/null +++ b/backend/src/contracts/prices.ts @@ -0,0 +1,91 @@ +import { logger } from '../utils/logger'; + +export interface XlmPriceData { + asset: string; + currency: string; + price: string; + source: string; + timestamp: string; +} + +const FETCH_TIMEOUT_MS = 3500; + +async function fetchFromCoinGecko(currency: string): Promise { + try { + const curLower = currency.toLowerCase(); + const url = `https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=${curLower}`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) return null; + const json = (await res.json()) as { stellar?: Record }; + const priceNum = json.stellar?.[curLower]; + if (priceNum !== undefined && Number.isFinite(priceNum)) { + return { + asset: 'XLM', + currency, + price: priceNum.toFixed(7), + source: 'coingecko', + timestamp: new Date().toISOString(), + }; + } + return null; + } catch { + return null; + } +} + +async function fetchFromBinance(currency: string): Promise { + if (currency !== 'USD' && currency !== 'USDT') return null; + try { + const url = 'https://api.binance.com/api/v3/ticker/price?symbol=XLMUSDT'; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) return null; + const json = (await res.json()) as { price?: string }; + if (json.price && !Number.isNaN(Number(json.price))) { + return { + asset: 'XLM', + currency, + price: Number(json.price).toFixed(7), + source: 'binance', + timestamp: new Date().toISOString(), + }; + } + return null; + } catch { + return null; + } +} + +async function fetchFromCoinbase(currency: string): Promise { + try { + const url = `https://api.coinbase.com/v2/prices/XLM-${currency}/spot`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) return null; + const json = (await res.json()) as { data?: { amount?: string } }; + if (json.data?.amount && !Number.isNaN(Number(json.data.amount))) { + return { + asset: 'XLM', + currency, + price: Number(json.data.amount).toFixed(7), + source: 'coinbase', + timestamp: new Date().toISOString(), + }; + } + return null; + } catch { + return null; + } +} + +export async function fetchXlmPrice(currency = 'USD'): Promise { + const providers = [fetchFromCoinGecko, fetchFromBinance, fetchFromCoinbase]; + + for (const provider of providers) { + const result = await provider(currency); + if (result) { + return result; + } + } + + logger.error('Failed to fetch XLM price from all public price sources', { currency }); + throw new Error('Failed to fetch XLM price from public source.'); +} diff --git a/backend/src/contracts/sequenceCache.ts b/backend/src/contracts/sequenceCache.ts new file mode 100644 index 0000000..9ced68c --- /dev/null +++ b/backend/src/contracts/sequenceCache.ts @@ -0,0 +1,109 @@ +import { Account } from '@stellar/stellar-sdk'; +import { StellarService } from './stellar'; +import { logger } from '../utils/logger'; + +const SEQUENCE_CACHE_TTL_MS = 30_000; + +interface CacheEntry { + account: Account; + cachedAt: number; +} + +interface HorizonErrorShape { + response?: { + data?: { + extras?: { result_codes?: { transaction?: string } }; + }; + }; +} + +/** True when the error is a Horizon `tx_bad_seq` rejection. */ +export function isBadSequenceError(err: unknown): boolean { + const horizonErr = err as HorizonErrorShape; + return horizonErr?.response?.data?.extras?.result_codes?.transaction === 'tx_bad_seq'; +} + +/** + * Caches each account's Stellar `Account` object (source of its sequence + * number) in memory, and serializes concurrent access per account so that + * two requests submitting transactions for the same source account in quick + * succession each get a distinct, correctly-incremented sequence number + * without a redundant `loadAccount` round-trip to Horizon for every request. + * + * `TransactionBuilder` mutates the `Account` instance it is given + * (incrementing its sequence number as part of `build()`), so reusing the + * same cached instance across calls is what keeps the local sequence in + * sync with what has actually been submitted. + */ +class SequenceCacheClass { + private cache = new Map(); + private queues = new Map>(); + + /** + * Runs `fn` with exclusive access to the cached `Account` for `publicKey`. + * Concurrent callers for the same account are queued so each sees the + * sequence number left behind by the previous caller's transaction build. + */ + async withAccount(publicKey: string, fn: (account: Account) => Promise): Promise { + const previous = this.queues.get(publicKey) ?? Promise.resolve(); + const run = previous + .catch(() => undefined) + .then(async () => { + const account = await this.getAccount(publicKey); + return fn(account); + }); + + this.queues.set(publicKey, run); + + try { + return await run; + } finally { + if (this.queues.get(publicKey) === run) { + this.queues.delete(publicKey); + } + } + } + + /** Drops the cached sequence for an account, forcing a reload from Horizon on next use. */ + invalidate(publicKey: string): void { + this.cache.delete(publicKey); + } + + private async getAccount(publicKey: string): Promise { + const cached = this.cache.get(publicKey); + if (cached && Date.now() - cached.cachedAt < SEQUENCE_CACHE_TTL_MS) { + return cached.account; + } + + const horizonAccount = await StellarService.loadAccount(publicKey); + const account = new Account(publicKey, horizonAccount.sequenceNumber()); + this.cache.set(publicKey, { account, cachedAt: Date.now() }); + return account; + } +} + +export const SequenceCache = new SequenceCacheClass(); + +/** + * Runs `buildAndSubmit` with a cached, serialized sequence number for + * `publicKey`. If Horizon rejects the resulting transaction with + * `tx_bad_seq` (e.g. the cache drifted from an out-of-band submission), the + * cache is invalidated and `buildAndSubmit` is retried exactly once against + * a freshly loaded account. + */ +export async function withSequenceRetry( + publicKey: string, + buildAndSubmit: (account: Account) => Promise +): Promise { + try { + return await SequenceCache.withAccount(publicKey, buildAndSubmit); + } catch (err) { + if (!isBadSequenceError(err)) { + throw err; + } + + logger.warn('Sequence number cache was stale; reloading and retrying once', { publicKey }); + SequenceCache.invalidate(publicKey); + return SequenceCache.withAccount(publicKey, buildAndSubmit); + } +} diff --git a/backend/src/contracts/stellar.ts b/backend/src/contracts/stellar.ts index 139d2f4..77c9e8e 100644 --- a/backend/src/contracts/stellar.ts +++ b/backend/src/contracts/stellar.ts @@ -87,6 +87,14 @@ class StellarServiceClass { return this.network; } + isTestnet(): boolean { + return this.network === (Networks.TESTNET as string); + } + + isMainnet(): boolean { + return this.network === (Networks.PUBLIC as string); + } + async call(operationName: string, request: () => Promise): Promise { return this.withRetry(operationName, request); } @@ -95,6 +103,10 @@ class StellarServiceClass { return this.withRetry('loadAccount', () => this.server.loadAccount(publicKey)); } + async getAccount(publicKey: string): Promise { + return this.loadAccount(publicKey); + } + async submitTransaction( transaction: Parameters[0] ): Promise { diff --git a/backend/src/contracts/transactions.ts b/backend/src/contracts/transactions.ts new file mode 100644 index 0000000..ae6bee6 --- /dev/null +++ b/backend/src/contracts/transactions.ts @@ -0,0 +1,42 @@ +import { Asset, TransactionBuilder, Operation, BASE_FEE, Memo } from '@stellar/stellar-sdk'; +import { StellarService } from './stellar'; + +export interface BuildUnsignedPaymentParams { + senderPublicKey: string; + destinationPublicKey: string; + assetCode: string; + assetIssuer: string; + amount: string; + memo?: string; +} + +/** + * Builds an unsigned payment transaction XDR for a wallet to sign + * client-side. Loads the sender's current sequence number from Horizon; + * does not submit anything. + */ +export async function buildUnsignedPayment(params: BuildUnsignedPaymentParams): Promise { + const { senderPublicKey, destinationPublicKey, assetCode, assetIssuer, amount, memo } = params; + + const account = await StellarService.loadAccount(senderPublicKey); + const asset = assetCode === 'XLM' ? Asset.native() : new Asset(assetCode, assetIssuer); + const network = StellarService.getNetwork(); + + const txBuilder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: network, + }).addOperation( + Operation.payment({ + destination: destinationPublicKey, + asset, + amount, + }) + ); + + if (memo) { + txBuilder.addMemo(Memo.text(memo)); + } + + const tx = txBuilder.setTimeout(30).build(); + return tx.toXDR(); +} diff --git a/backend/src/contracts/trustlines.ts b/backend/src/contracts/trustlines.ts index 6025161..113571a 100644 --- a/backend/src/contracts/trustlines.ts +++ b/backend/src/contracts/trustlines.ts @@ -1,6 +1,7 @@ import { Asset, Keypair, TransactionBuilder, Operation, BASE_FEE } from '@stellar/stellar-sdk'; import { StellarService } from './stellar'; import { invalidateBalanceCache } from '../cache/balances'; +import { withSequenceRetry } from './sequenceCache'; export interface TrustlineParams { accountSecret: string; @@ -9,6 +10,13 @@ export interface TrustlineParams { limit?: string; } +export interface BuildUnsignedTrustlineParams { + accountPublicKey: string; + assetCode: string; + assetIssuer: string; + limit?: string; +} + /** * Establishes a trustline so an account can hold a community token. * Must be called before the account can receive or hold the asset. @@ -18,8 +26,40 @@ export async function establishTrustline(params: TrustlineParams): Promise { + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: network, + }) + .addOperation( + Operation.changeTrust({ + asset, + ...(limit !== undefined && { limit }), + }) + ) + .setTimeout(30) + .build(); + + tx.sign(accountKeypair); + return StellarService.submitTransaction(tx); + }); - const account = await StellarService.loadAccount(accountKeypair.publicKey()); + await invalidateBalanceCache([accountKeypair.publicKey()]); + return result.hash; +} + +/** + * Builds an unsigned XDR transaction for establishing a trustline for client-side signing. + */ +export async function buildUnsignedTrustline( + params: BuildUnsignedTrustlineParams +): Promise { + const { accountPublicKey, assetCode, assetIssuer, limit } = params; + + const network = StellarService.getNetwork(); + const account = await StellarService.loadAccount(accountPublicKey); const asset = new Asset(assetCode, assetIssuer); const tx = new TransactionBuilder(account, { @@ -35,11 +75,7 @@ export async function establishTrustline(params: TrustlineParams): Promise { +const RUN = Boolean(process.env.DATABASE_URL); +const describeIf = RUN ? describe : describe.skip; + +describeIf('rollback', () => { const migrationsDirectory = path.join(__dirname, '..', 'migrations'); - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + let pool: Pool; let client: PoolClient; + beforeAll(() => { + pool = new Pool({ connectionString: process.env.DATABASE_URL }); + }); + + afterAll(async () => { + await pool?.end(); + }); + beforeEach(async () => { client = await pool.connect(); await client.query('BEGIN'); diff --git a/backend/src/db/seed-data.ts b/backend/src/db/seed-data.ts index 753aad1..1e95d7f 100644 --- a/backend/src/db/seed-data.ts +++ b/backend/src/db/seed-data.ts @@ -124,12 +124,7 @@ export async function seedBaselineData(client: PoolClient): Promise + Returns current XLM market price (default XLM/USD) fetched from public market data providers. + Responses may be served from a Redis cache for up to 30 seconds. + parameters: + - name: currency + in: query + required: false + description: Target fiat/quote currency (e.g., USD, EUR, GBP). Defaults to USD. + schema: + type: string + pattern: '^[A-Za-z]{3,4}$' + default: USD + responses: + '200': + description: Current market price for XLM + content: + application/json: + schema: + $ref: '#/components/schemas/PriceResponse' + '400': { $ref: '#/components/responses/ValidationError' } + '502': + description: Failed to fetch price from public source + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/accounts/{publicKey}: + parameters: + - name: publicKey + in: path + required: true + description: Stellar public key of the account to inspect. + schema: + $ref: '#/components/schemas/StellarKey' + get: + tags: [Accounts] + summary: Get full Stellar account details + description: > + Returns full Stellar account details from Horizon for the given public key, + including sequence number, balances, signers, thresholds, flags, and account data entries. + responses: + '200': + description: Full account details + content: + application/json: + schema: + $ref: '#/components/schemas/AccountDetailsResponse' + '400': { $ref: '#/components/responses/ValidationError' } + '404': { $ref: '#/components/responses/NotFound' } + '502': + description: Horizon is temporarily unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/balances/{publicKey}: parameters: - name: publicKey @@ -318,6 +385,59 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/webhooks/stellar: + post: + tags: [Webhooks] + summary: Receive a Stellar account/transaction event notification + description: > + Accepts event notifications from a trusted Stellar webhook source + (e.g. a Horizon-event forwarder). The request must carry an + `X-Stellar-Webhook-Signature` header containing the hex-encoded + HMAC-SHA256 digest of the raw request body, keyed with the server's + `STELLAR_WEBHOOK_SECRET`. The signature is verified before the body + is validated, so a request with an invalid signature never reaches + payload validation. + parameters: + - name: X-Stellar-Webhook-Signature + in: header + required: true + description: Hex-encoded HMAC-SHA256 digest of the raw request body. + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StellarWebhookPayload' + responses: + '200': + description: Event accepted + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: object + required: [received, eventId] + properties: + received: { type: boolean, example: true } + eventId: { type: string } + '400': { $ref: '#/components/responses/ValidationError' } + '401': + description: Missing or invalid webhook signature + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: The server has no webhook secret configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/transactions/unsigned: post: tags: [Transactions] @@ -353,6 +473,41 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/trustlines/build: + post: + tags: [Transactions, Tokens] + summary: Build an unsigned Stellar trustline establishment transaction + description: > + Loads the account and its current sequence number from Horizon, + then returns a single-operation changeTrust transaction XDR for wallet signing. + The transaction is not signed or submitted by this endpoint. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BuildTrustlineRequest' + responses: + '200': + description: Unsigned trustline transaction built successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildTrustlineResponse' + '400': { $ref: '#/components/responses/ValidationError' } + '404': + description: The Stellar account does not exist on the configured network + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: Horizon is temporarily unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/tokens/airdrop: post: tags: [Transactions] @@ -1248,6 +1403,28 @@ components: type: object properties: error: { type: string } + StellarWebhookPayload: + type: object + required: [eventId, eventType, occurredAt, data] + properties: + eventId: + type: string + minLength: 1 + eventType: + type: string + enum: + - transaction.succeeded + - transaction.failed + - account.created + - trustline.created + - trustline.removed + - payment.received + occurredAt: + type: string + format: date-time + data: + type: object + additionalProperties: true TokenTransferRequest: type: object required: [signedXdr] @@ -1304,6 +1481,40 @@ components: xdr: type: string description: Base64-encoded unsigned Stellar transaction envelope XDR. + BuildTrustlineRequest: + type: object + required: [accountPublicKey, assetCode, assetIssuer] + properties: + accountPublicKey: + $ref: '#/components/schemas/StellarKey' + description: Stellar public key of the account establishing the trustline. + assetCode: + type: string + pattern: '^[A-Za-z0-9]{1,12}$' + description: Stellar asset code to trust. + assetIssuer: + $ref: '#/components/schemas/StellarKey' + description: Stellar public key of the asset issuer. + limit: + type: string + pattern: '^(?:0|[1-9]\d*)(?:\.\d{1,7})?$' + description: Optional maximum amount of the asset to trust. + example: + accountPublicKey: GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR + assetCode: COOP + assetIssuer: GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U + limit: '1000' + BuildTrustlineResponse: + type: object + required: [data] + properties: + data: + type: object + required: [xdr] + properties: + xdr: + type: string + description: Base64-encoded unsigned Stellar transaction envelope XDR. AirdropRequest: type: object required: [communityId, amount, issuerSecret] @@ -1472,3 +1683,109 @@ components: assetIssuer: { $ref: '#/components/schemas/StellarKey' } purpose: { type: string, maxLength: 280 } dueAt: { type: string, format: date-time } + PriceData: + type: object + required: [asset, currency, price, source, timestamp] + properties: + asset: + type: string + example: XLM + currency: + type: string + example: USD + price: + type: string + example: '0.1888200' + source: + type: string + example: coingecko + timestamp: + type: string + format: date-time + PriceResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/PriceData' + AccountThresholds: + type: object + properties: + low_threshold: { type: integer } + med_threshold: { type: integer } + high_threshold: { type: integer } + AccountFlags: + type: object + properties: + auth_required: { type: boolean } + auth_revocable: { type: boolean } + auth_immutable: { type: boolean } + auth_clawback_enabled: { type: boolean } + AccountSigner: + type: object + properties: + key: { type: string } + weight: { type: integer } + type: { type: string } + sponsor: { type: string } + AccountBalanceLine: + type: object + properties: + asset_type: { type: string } + balance: { type: string } + limit: { type: string } + buying_liabilities: { type: string } + selling_liabilities: { type: string } + asset_code: { type: string } + asset_issuer: { type: string } + last_modified_ledger: { type: integer } + is_authorized: { type: boolean } + is_authorized_to_maintain_liabilities: { type: boolean } + is_clawback_enabled: { type: boolean } + sponsor: { type: string } + AccountDetails: + type: object + required: + - id + - account_id + - sequence + - subentry_count + - last_modified_ledger + - thresholds + - flags + - balances + - signers + - data + properties: + id: { $ref: '#/components/schemas/StellarKey' } + account_id: { $ref: '#/components/schemas/StellarKey' } + sequence: { type: string } + sequence_ledger: { type: integer } + sequence_time: { type: string } + subentry_count: { type: integer } + inflation_destination: { type: string } + home_domain: { type: string } + last_modified_ledger: { type: integer } + last_modified_time: { type: string } + thresholds: { $ref: '#/components/schemas/AccountThresholds' } + flags: { $ref: '#/components/schemas/AccountFlags' } + balances: + type: array + items: { $ref: '#/components/schemas/AccountBalanceLine' } + signers: + type: array + items: { $ref: '#/components/schemas/AccountSigner' } + data: + type: object + additionalProperties: { type: string } + num_sponsoring: { type: integer } + num_sponsored: { type: integer } + sponsor: { type: string } + paging_token: { type: string } + AccountDetailsResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/AccountDetails' + diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information.