Skip to content

Commit a6e519d

Browse files
feat: add backend proxy for Horizon transaction history
1 parent 9e70ea8 commit a6e519d

7 files changed

Lines changed: 627 additions & 0 deletions

File tree

backend/src/horizon-rate-limit.service.ts

Whitespace-only changes.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/**
2+
* Horizon proxy integration tests.
3+
*
4+
* Uses mocked fetch and mocked Redis — no real Horizon credentials required.
5+
* Covers: field filtering, rate limiting, 429 response, cache hit, address validation.
6+
*/
7+
8+
import { Test, TestingModule } from "@nestjs/testing";
9+
import { INestApplication, HttpStatus } from "@nestjs/common";
10+
import * as request from "supertest";
11+
import { ConfigModule } from "@nestjs/config";
12+
import { HorizonModule } from "../horizon.module";
13+
import { RedisService } from "../../cache/redis.service";
14+
15+
// ── Fixtures ──────────────────────────────────────────────────────────────────
16+
17+
const VALID_ACCOUNT = "GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I";
18+
19+
const MOCK_HORIZON_RESPONSE = {
20+
_links: {
21+
self: {
22+
href: "https://horizon-testnet.stellar.org/accounts/G.../operations?limit=20",
23+
},
24+
next: {
25+
href: "https://horizon-testnet.stellar.org/accounts/G.../operations?cursor=abc123&limit=20",
26+
},
27+
},
28+
_embedded: {
29+
records: [
30+
{
31+
id: "1234567890",
32+
paging_token: "token-1",
33+
type: "payment",
34+
type_int: 1,
35+
created_at: "2024-01-15T10:00:00Z",
36+
transaction_hash: "aabbcc",
37+
transaction_successful: true,
38+
source_account: VALID_ACCOUNT,
39+
asset_type: "native",
40+
amount: "100.0000000",
41+
from: VALID_ACCOUNT,
42+
to: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
43+
// Fields that must be stripped:
44+
_links: { self: { href: "..." } },
45+
offer_id: "99",
46+
sponsor: "GSOME_SPONSOR",
47+
funder: "GFUNDER",
48+
},
49+
{
50+
// DEX operation — must be filtered out
51+
id: "999",
52+
paging_token: "token-dex",
53+
type: "manage_sell_offer",
54+
type_int: 3,
55+
created_at: "2024-01-15T09:00:00Z",
56+
transaction_hash: "dex-hash",
57+
transaction_successful: true,
58+
source_account: VALID_ACCOUNT,
59+
offer_id: "42",
60+
price: "1.5",
61+
},
62+
],
63+
},
64+
};
65+
66+
// ── Redis mock ────────────────────────────────────────────────────────────────
67+
68+
function createRedisMock() {
69+
const store = new Map<string, string>();
70+
return {
71+
get: jest.fn(async <T>(key: string): Promise<T | null> => {
72+
const val = store.get(key);
73+
return val ? (JSON.parse(val) as T) : null;
74+
}),
75+
set: jest.fn(async (key: string, value: unknown) => {
76+
store.set(key, JSON.stringify(value));
77+
}),
78+
del: jest.fn(async (key: string) => {
79+
store.delete(key);
80+
}),
81+
delPattern: jest.fn(),
82+
getClient: jest.fn(() => ({
83+
multi: jest.fn(() => ({
84+
zremrangebyscore: jest.fn().mockReturnThis(),
85+
zcard: jest.fn().mockReturnThis(),
86+
zadd: jest.fn().mockReturnThis(),
87+
expire: jest.fn().mockReturnThis(),
88+
exec: jest.fn().mockResolvedValue([
89+
[null, 1],
90+
[null, 0], // zcard returns 0 — under limit
91+
[null, 1],
92+
[null, 1],
93+
]),
94+
})),
95+
zremrangebyscore: jest.fn(),
96+
})),
97+
ping: jest.fn(async () => true),
98+
onModuleDestroy: jest.fn(),
99+
};
100+
}
101+
102+
// ── Test setup ────────────────────────────────────────────────────────────────
103+
104+
describe("HorizonController (integration)", () => {
105+
let app: INestApplication;
106+
let redisMock: ReturnType<typeof createRedisMock>;
107+
let fetchSpy: jest.SpyInstance;
108+
109+
beforeEach(async () => {
110+
redisMock = createRedisMock();
111+
112+
// Mock global fetch — no real Horizon call made
113+
fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({
114+
ok: true,
115+
status: 200,
116+
json: async () => MOCK_HORIZON_RESPONSE,
117+
} as Response);
118+
119+
// Override STELLAR_NETWORK env so network config does not throw
120+
process.env.STELLAR_NETWORK = "testnet";
121+
process.env.STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
122+
123+
const moduleRef: TestingModule = await Test.createTestingModule({
124+
imports: [
125+
ConfigModule.forRoot({
126+
isGlobal: true,
127+
ignoreEnvFile: true,
128+
load: [
129+
() => ({
130+
REDIS_URL: "redis://mock:6379",
131+
STELLAR_NETWORK: "testnet",
132+
STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015",
133+
SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org",
134+
CONTRACT_ID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
135+
}),
136+
],
137+
}),
138+
HorizonModule,
139+
],
140+
})
141+
.overrideProvider(RedisService)
142+
.useValue(redisMock)
143+
.compile();
144+
145+
app = moduleRef.createNestApplication();
146+
app.setGlobalPrefix("api");
147+
await app.init();
148+
});
149+
150+
afterEach(async () => {
151+
jest.restoreAllMocks();
152+
await app.close();
153+
});
154+
155+
// ── Field filtering ───────────────────────────────────────────────────────
156+
157+
it("returns only payment operations and strips internal Horizon fields", async () => {
158+
const res = await request(app.getHttpServer())
159+
.get("/api/horizon/transactions")
160+
.query({ account: VALID_ACCOUNT });
161+
162+
expect(res.status).toBe(HttpStatus.OK);
163+
expect(res.body.records).toHaveLength(1); // manage_sell_offer filtered out
164+
const record = res.body.records[0];
165+
166+
// Required fields present
167+
expect(record).toMatchObject({
168+
id: "1234567890",
169+
type: "payment",
170+
amount: "100.0000000",
171+
transaction_successful: true,
172+
});
173+
174+
// Stripped fields must not be present
175+
expect(record).not.toHaveProperty("_links");
176+
expect(record).not.toHaveProperty("offer_id");
177+
expect(record).not.toHaveProperty("sponsor");
178+
expect(record).not.toHaveProperty("funder");
179+
});
180+
181+
it("exposes next_cursor when Horizon provides a next link", async () => {
182+
const res = await request(app.getHttpServer())
183+
.get("/api/horizon/transactions")
184+
.query({ account: VALID_ACCOUNT });
185+
186+
expect(res.status).toBe(HttpStatus.OK);
187+
expect(res.body.next_cursor).toBe("abc123");
188+
});
189+
190+
// ── Response headers must not leak API keys ───────────────────────────────
191+
192+
it("does not expose Authorization or Horizon API key in response headers", async () => {
193+
const res = await request(app.getHttpServer())
194+
.get("/api/horizon/transactions")
195+
.query({ account: VALID_ACCOUNT });
196+
197+
expect(res.headers).not.toHaveProperty("authorization");
198+
expect(res.headers).not.toHaveProperty("x-horizon-api-key");
199+
});
200+
201+
// ── Address validation ────────────────────────────────────────────────────
202+
203+
it("returns 400 for a missing account parameter", async () => {
204+
const res = await request(app.getHttpServer()).get("/api/horizon/transactions");
205+
expect(res.status).toBe(HttpStatus.BAD_REQUEST);
206+
});
207+
208+
it("returns 400 for an invalid Stellar address", async () => {
209+
const res = await request(app.getHttpServer())
210+
.get("/api/horizon/transactions")
211+
.query({ account: "not-a-stellar-address" });
212+
213+
expect(res.status).toBe(HttpStatus.BAD_REQUEST);
214+
});
215+
216+
// ── Cache hit ─────────────────────────────────────────────────────────────
217+
218+
it("serves from cache on second identical request without calling Horizon again", async () => {
219+
await request(app.getHttpServer())
220+
.get("/api/horizon/transactions")
221+
.query({ account: VALID_ACCOUNT });
222+
223+
// Seed the cache with what the first request stored
224+
const cachedValue = { records: [], next_cursor: undefined };
225+
redisMock.get.mockResolvedValueOnce(cachedValue);
226+
227+
await request(app.getHttpServer())
228+
.get("/api/horizon/transactions")
229+
.query({ account: VALID_ACCOUNT });
230+
231+
// fetch should only have been called once (the second served from cache)
232+
expect(fetchSpy).toHaveBeenCalledTimes(1);
233+
});
234+
235+
// ── Rate limiting ─────────────────────────────────────────────────────────
236+
237+
it("returns 429 with Retry-After header when rate limit is exceeded", async () => {
238+
// Make zcard return a count at the limit
239+
redisMock.getClient.mockReturnValue({
240+
multi: jest.fn(() => ({
241+
zremrangebyscore: jest.fn().mockReturnThis(),
242+
zcard: jest.fn().mockReturnThis(),
243+
zadd: jest.fn().mockReturnThis(),
244+
expire: jest.fn().mockReturnThis(),
245+
exec: jest.fn().mockResolvedValue([
246+
[null, 1],
247+
[null, 30], // at limit
248+
[null, 1],
249+
[null, 1],
250+
]),
251+
})),
252+
zremrangebyscore: jest.fn(),
253+
});
254+
255+
const res = await request(app.getHttpServer())
256+
.get("/api/horizon/transactions")
257+
.query({ account: VALID_ACCOUNT });
258+
259+
expect(res.status).toBe(429);
260+
expect(res.headers).toHaveProperty("retry-after");
261+
expect(res.body.error).toBe("Too Many Requests");
262+
});
263+
264+
// ── Horizon upstream failure ──────────────────────────────────────────────
265+
266+
it("returns 502 when Horizon is unreachable", async () => {
267+
fetchSpy.mockRejectedValueOnce(new Error("ECONNREFUSED"));
268+
269+
const res = await request(app.getHttpServer())
270+
.get("/api/horizon/transactions")
271+
.query({ account: VALID_ACCOUNT });
272+
273+
expect(res.status).toBe(HttpStatus.BAD_GATEWAY);
274+
// Error body must not contain any API key
275+
expect(JSON.stringify(res.body)).not.toContain("Bearer");
276+
});
277+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Horizon Transaction DTOs
3+
*
4+
* Fields forwarded from Horizon and why:
5+
*
6+
* FORWARDED:
7+
* id — unique operation identifier; used by frontend for deduplication
8+
* paging_token — cursor for paginated fetches
9+
* type — operation type string (e.g. "payment", "change_trust")
10+
* type_int — numeric type; easier for frontend switch/case
11+
* created_at — ISO timestamp of the ledger close containing this operation
12+
* transaction_hash — links operation to its parent transaction; shown in explorer links
13+
* transaction_successful — guards UI from displaying failed operations as completed
14+
* source_account — sender address
15+
* asset_type — "native" | "credit_alphanum4" | "credit_alphanum12"
16+
* asset_code — token ticker (undefined for XLM)
17+
* asset_issuer — issuer address (undefined for XLM)
18+
* amount — transfer amount as string (Stellar uses string to avoid float loss)
19+
* from / to — payment parties
20+
*
21+
* STRIPPED:
22+
* _links — Horizon HAL links; internal navigation not needed by frontend
23+
* records[].links — same reason
24+
* funder / account — create_account-specific; not used in payment history view
25+
* starting_balance — create_account-specific
26+
* offer_id — DEX offer internals; not relevant to policy-payment history
27+
* price / price_r — DEX pricing fields
28+
* buying_* / selling_* — DEX asset pair fields
29+
* claimable_balance_id — claimable-balance internals
30+
* sponsor — reserve-sponsoring; not displayed in current UI
31+
* bump_to — bump_sequence internals
32+
* authorize* — change_trust flag fields
33+
* limit — change_trust limit; not shown
34+
* set_flags* / clear_flags* — account flags
35+
* home_domain / thresholds / signers / inflation_dest — account meta changes
36+
*
37+
* HORIZON FINALITY LAG:
38+
* Stellar closes a ledger approximately every 5 seconds. The `created_at` field
39+
* reflects ledger close time, not submission time. Operations are final once
40+
* included in a closed ledger — there is no probabilistic finality.
41+
* However, Horizon ingestion may lag 1–3 ledgers (~5–15 seconds) behind the
42+
* network tip. The frontend should show a "transactions may take up to 15 seconds
43+
* to appear" notice and avoid treating a missing transaction as definitively
44+
* failed until at least 30 seconds have elapsed.
45+
*/
46+
47+
export interface HorizonOperationRecord {
48+
id: string;
49+
paging_token: string;
50+
type: string;
51+
type_int: number;
52+
created_at: string;
53+
transaction_hash: string;
54+
transaction_successful: boolean;
55+
source_account: string;
56+
// Payment / path-payment fields (optional — only present on relevant types)
57+
asset_type?: string;
58+
asset_code?: string;
59+
asset_issuer?: string;
60+
amount?: string;
61+
from?: string;
62+
to?: string;
63+
}
64+
65+
export interface HorizonTransactionResponse {
66+
records: HorizonOperationRecord[];
67+
next_cursor?: string;
68+
}

0 commit comments

Comments
 (0)