From a1795dd2d11ca60e8577c3326c83f1640f8d50aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:10:42 +0000 Subject: [PATCH 1/6] feat(api): map all known Stellar result codes to friendly error messages Extend the Horizon error mapper with the full set of documented transaction and operation result codes (tx_too_late, tx_bad_auth, op_low_reserve, op_no_issuer, and others) so callers get an actionable message instead of falling through to the generic 502 network-error response. Closes #166 --- CHANGELOG.md | 2 + .../api/utils/__tests__/horizonError.test.ts | 167 ++++++++++++++++++ backend/src/api/utils/horizonError.ts | 68 +++++++ 3 files changed, 237 insertions(+) create mode 100644 backend/src/api/utils/__tests__/horizonError.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ff3b3..6e2b339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- 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). + - `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. 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. */ From 8a3b407ea691d233f8d3a9c1799a5447307c7435 Mon Sep 17 00:00:00 2001 From: BigNathan1 Date: Sat, 29 Aug 2026 02:11:56 +0100 Subject: [PATCH 2/6] docs: add contributor points leaderboard, formalize sole maintainer Formalizes BigNathan1 as sole maintainer with write access to main, and replaces the old maintainer-nomination path with the CONTRIBUTORS.md leaderboard for recognition. --- CONTRIBUTING.md | 11 ++++++----- CONTRIBUTORS.md | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 CONTRIBUTORS.md 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. From fa310bd32100a1754843ab4ff7f1a2cc4d7f1469 Mon Sep 17 00:00:00 2001 From: Muhammad Abass Mudasir Date: Sat, 29 Aug 2026 02:15:55 +0100 Subject: [PATCH 3/6] feat(api): add GET /api/v1/accounts/:publicKey endpoint (#122) Closes #153. Adds full Stellar account details endpoint (sequence, thresholds, flags, balances, signers, data) with Zod validation, exponential backoff retries on Horizon 429/503, mapped error responses, unit + testnet integration tests, and OpenAPI/CHANGELOG updates. --- CHANGELOG.md | 1 + .../__tests__/accounts.integration.test.ts | 47 +++ .../src/api/routes/__tests__/accounts.test.ts | 288 ++++++++++++++++++ backend/src/api/routes/accounts.ts | 47 +++ backend/src/api/routes/index.ts | 2 + backend/src/api/schemas/account.ts | 11 + backend/src/api/utils/stellarAccount.ts | 87 ++++++ backend/src/contracts/stellar.ts | 4 + backend/src/contracts/transactions.ts | 88 ++++++ .../db/__tests__/migrate.integration.test.ts | 3 +- .../migrate.rollback.integration.test.ts | 15 +- backend/src/db/seed-data.ts | 7 +- docs/openapi.yaml | 113 +++++++ 13 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 backend/src/api/routes/__tests__/accounts.integration.test.ts create mode 100644 backend/src/api/routes/__tests__/accounts.test.ts create mode 100644 backend/src/api/routes/accounts.ts create mode 100644 backend/src/api/schemas/account.ts create mode 100644 backend/src/api/utils/stellarAccount.ts create mode 100644 backend/src/contracts/transactions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ff3b3..12fdec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `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. 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/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..d7bf92f 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -4,6 +4,7 @@ import { tokenRouter } from './tokens'; import { balanceRouter } from './balances'; import { loanRouter } from './loans'; import { transactionRouter } from './transactions'; +import { accountsRouter } from './accounts'; /** * Combined API router. Mounted under the `/api/v1` version prefix in app.ts so @@ -17,3 +18,4 @@ apiRouter.use('/tokens', tokenRouter); apiRouter.use('/balances', balanceRouter); apiRouter.use('/loans', loanRouter); apiRouter.use('/transactions', transactionRouter); +apiRouter.use('/accounts', accountsRouter); 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/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/contracts/stellar.ts b/backend/src/contracts/stellar.ts index 139d2f4..21435c7 100644 --- a/backend/src/contracts/stellar.ts +++ b/backend/src/contracts/stellar.ts @@ -95,6 +95,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..45a441e --- /dev/null +++ b/backend/src/contracts/transactions.ts @@ -0,0 +1,88 @@ +import { + Asset, + Keypair, + TransactionBuilder, + Operation, + BASE_FEE, + Memo, + Transaction, +} from '@stellar/stellar-sdk'; +import { StellarService } from './stellar'; +import { invalidateBalanceCache } from '../cache/balances'; + +export interface PaymentParams { + senderSecret: string; + destinationPublicKey: string; + assetCode: string; + assetIssuer: string; + amount: string; + memo?: string; +} + +export interface BuildUnsignedPaymentParams { + senderPublicKey: string; + destinationPublicKey: string; + assetCode: string; + assetIssuer: string; + amount: string; + memo?: string; +} + +/** + * Submits a signed payment from a server-held keypair (e.g., community distributor). + */ +export async function submitPayment(params: PaymentParams): Promise { + const { senderSecret, destinationPublicKey, assetCode, assetIssuer, amount, memo } = params; + + const senderKeypair = Keypair.fromSecret(senderSecret); + const network = StellarService.getNetwork(); + + const account = await StellarService.loadAccount(senderKeypair.publicKey()); + const asset = assetCode === 'XLM' ? Asset.native() : new Asset(assetCode, assetIssuer); + + 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(); + tx.sign(senderKeypair); + + const result = await StellarService.submitTransaction(tx); + await invalidateBalanceCache([senderKeypair.publicKey(), destinationPublicKey]); + return result.hash; +} + +/** + * Builds an unsigned XDR transaction for client-side signing via Freighter. + */ +export async function buildUnsignedPayment(params: BuildUnsignedPaymentParams): Promise { + const { senderPublicKey, destinationPublicKey, assetCode, assetIssuer, amount, memo } = params; + + const network = StellarService.getNetwork(); + + const account = await StellarService.loadAccount(senderPublicKey); + const asset = assetCode === 'XLM' ? Asset.native() : new Asset(assetCode, assetIssuer); + + const txBuilder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: network, + }).addOperation(Operation.payment({ destination: destinationPublicKey, asset, amount })); + + if (memo) { + txBuilder.addMemo(Memo.text(memo)); + } + + return txBuilder.setTimeout(30).build().toXDR(); +} + +export async function submitSignedXdr(xdr: string): Promise { + const network = StellarService.getNetwork(); + const tx = new Transaction(xdr, network); + const result = await StellarService.submitTransaction(tx); + return result.hash; +} diff --git a/backend/src/db/__tests__/migrate.integration.test.ts b/backend/src/db/__tests__/migrate.integration.test.ts index 2e84b50..9ccd077 100644 --- a/backend/src/db/__tests__/migrate.integration.test.ts +++ b/backend/src/db/__tests__/migrate.integration.test.ts @@ -17,7 +17,8 @@ const describeIf = RUN ? describe : describe.skip; jest.setTimeout(120_000); function databaseUrlFor(databaseName: string): string { - const url = new URL(configuredDatabaseUrl!); + if (!configuredDatabaseUrl) return ''; + const url = new URL(configuredDatabaseUrl); url.pathname = `/${databaseName}`; return url.toString(); } diff --git a/backend/src/db/__tests__/migrate.rollback.integration.test.ts b/backend/src/db/__tests__/migrate.rollback.integration.test.ts index b13f83f..1b5f82e 100644 --- a/backend/src/db/__tests__/migrate.rollback.integration.test.ts +++ b/backend/src/db/__tests__/migrate.rollback.integration.test.ts @@ -3,11 +3,22 @@ import path from 'path'; import { Pool, PoolClient } from 'pg'; import { runPending, rollback } from '../migrate'; -describe('rollback', () => { +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 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 @@ -1472,3 +1504,84 @@ components: assetIssuer: { $ref: '#/components/schemas/StellarKey' } purpose: { type: string, maxLength: 280 } dueAt: { type: string, format: date-time } + 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' + From 4823d9e0be0cce76f10d47273ca729d88ce78ab6 Mon Sep 17 00:00:00 2001 From: Muhammad Abass Mudasir Date: Sat, 29 Aug 2026 02:31:53 +0100 Subject: [PATCH 4/6] feat(api): add POST /api/v1/trustlines/build for unsigned trustline XDR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #155. Adds POST /api/v1/trustlines/build for unsigned changeTrust XDR (Freighter/Albedo client-side signing), with optional custom limits, Zod validation, mapped Horizon errors, tests, and OpenAPI/CHANGELOG updates. Rebased onto main by the maintainer to resolve a trivial add/add conflict on transactions.ts against #586 — re-verified with tsc --noEmit and the full trustlines/accounts test suite before merge. --- CHANGELOG.md | 1 + .../api/routes/__tests__/trustlines.test.ts | 190 ++++++++++++++++++ backend/src/api/routes/index.ts | 2 + backend/src/api/routes/trustlines.ts | 33 +++ backend/src/api/schemas/trustline.ts | 27 +++ .../contracts/__tests__/trustlines.test.ts | 64 +++++- backend/src/contracts/trustlines.ts | 35 ++++ docs/openapi.yaml | 69 +++++++ 8 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 backend/src/api/routes/__tests__/trustlines.test.ts create mode 100644 backend/src/api/routes/trustlines.ts create mode 100644 backend/src/api/schemas/trustline.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fdec3..006bcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `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). 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/index.ts b/backend/src/api/routes/index.ts index d7bf92f..3025989 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -4,6 +4,7 @@ import { tokenRouter } from './tokens'; import { balanceRouter } from './balances'; import { loanRouter } from './loans'; import { transactionRouter } from './transactions'; +import { trustlineRouter } from './trustlines'; import { accountsRouter } from './accounts'; /** @@ -18,4 +19,5 @@ apiRouter.use('/tokens', tokenRouter); apiRouter.use('/balances', balanceRouter); apiRouter.use('/loans', loanRouter); apiRouter.use('/transactions', transactionRouter); +apiRouter.use('/trustlines', trustlineRouter); apiRouter.use('/accounts', accountsRouter); 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/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/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/trustlines.ts b/backend/src/contracts/trustlines.ts index 6025161..9eadfdd 100644 --- a/backend/src/contracts/trustlines.ts +++ b/backend/src/contracts/trustlines.ts @@ -9,6 +9,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. @@ -42,6 +49,34 @@ export async function establishTrustline(params: TrustlineParams): 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, { + fee: BASE_FEE, + networkPassphrase: network, + }) + .addOperation( + Operation.changeTrust({ + asset, + ...(limit !== undefined && { limit }), + }) + ) + .setTimeout(30) + .build(); + + return tx.toXDR(); +} + export async function hasTrustline( publicKey: string, assetCode: string, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f9e8db7..f33e0cf 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -385,6 +385,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] @@ -1336,6 +1371,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] From 91c724e08574443e231ec6e2b5f3a3192e13be4c Mon Sep 17 00:00:00 2001 From: Muhammad Abass Mudasir Date: Sat, 29 Aug 2026 02:36:26 +0100 Subject: [PATCH 5/6] feat(contracts): expand StellarService with isTestnet and isMainnet helpers Closes #172. Adds isTestnet()/isMainnet() boolean helpers to StellarService, with unit tests and CHANGELOG update. Rebased onto main by the maintainer to resolve a CHANGELOG.md conflict; re-verified with tsc --noEmit and the stellar.test.ts suite before merge. --- CHANGELOG.md | 1 + .../src/contracts/__tests__/stellar.test.ts | 104 ++++++++++++++++++ backend/src/contracts/stellar.ts | 8 ++ 3 files changed, 113 insertions(+) create mode 100644 backend/src/contracts/__tests__/stellar.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 006bcb3..02cbca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `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). 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/stellar.ts b/backend/src/contracts/stellar.ts index 21435c7..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); } From ea79c9caed92b4875a9b8608b526f59664d786bb Mon Sep 17 00:00:00 2001 From: Muhammad Abass Mudasir Date: Sat, 29 Aug 2026 02:42:36 +0100 Subject: [PATCH 6/6] feat(api): add GET /api/v1/prices/xlm endpoint (#137) Closes #137. Adds GET /api/v1/prices/xlm with CoinGecko/Binance/Coinbase failover, 3.5s per-provider timeout, 30s Redis caching, Zod validation, and unit/integration tests plus OpenAPI/CHANGELOG updates. Rebased onto main by the maintainer to resolve conflicts against #586/#587/#588 (all merged just ahead of this one) in CHANGELOG.md, routes/index.ts, and openapi.yaml; re-verified with tsc --noEmit and the full price/account/trustline test suites before merge. --- .gitignore | 1 + CHANGELOG.md | 1 + .../__tests__/prices.integration.test.ts | 26 +++ .../src/api/routes/__tests__/prices.test.ts | 181 ++++++++++++++++++ backend/src/api/routes/index.ts | 2 + backend/src/api/routes/prices.ts | 51 +++++ backend/src/api/schemas/price.ts | 13 ++ backend/src/cache/prices.ts | 47 +++++ backend/src/contracts/prices.ts | 91 +++++++++ docs/openapi.yaml | 58 ++++++ frontend/next-env.d.ts | 5 + 11 files changed, 476 insertions(+) create mode 100644 backend/src/api/routes/__tests__/prices.integration.test.ts create mode 100644 backend/src/api/routes/__tests__/prices.test.ts create mode 100644 backend/src/api/routes/prices.ts create mode 100644 backend/src/api/schemas/price.ts create mode 100644 backend/src/cache/prices.ts create mode 100644 backend/src/contracts/prices.ts create mode 100644 frontend/next-env.d.ts 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 02cbca3..a7ac47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `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). 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/index.ts b/backend/src/api/routes/index.ts index 3025989..b6d4fbd 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -4,6 +4,7 @@ import { tokenRouter } from './tokens'; import { balanceRouter } from './balances'; import { loanRouter } from './loans'; import { transactionRouter } from './transactions'; +import { pricesRouter } from './prices'; import { trustlineRouter } from './trustlines'; import { accountsRouter } from './accounts'; @@ -19,5 +20,6 @@ apiRouter.use('/tokens', tokenRouter); apiRouter.use('/balances', balanceRouter); apiRouter.use('/loans', loanRouter); apiRouter.use('/transactions', transactionRouter); +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/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/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/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/docs/openapi.yaml b/docs/openapi.yaml index f33e0cf..efdf601 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -11,6 +11,8 @@ tags: description: Community token metadata and operations - name: Balances description: Stellar account balances and related balance views + - name: Prices + description: Public market price feeds for Stellar assets - name: Accounts description: Full Stellar account details and state - name: Transactions @@ -110,6 +112,37 @@ paths: '400': description: Missing or blank `q` query parameter + /api/v1/prices/xlm: + get: + tags: [Prices] + summary: Get XLM price + description: > + 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 @@ -1573,6 +1606,31 @@ 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: 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.