diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts index 3e72adb..8d8b547 100644 --- a/src/lib/soroban.service.test.ts +++ b/src/lib/soroban.service.test.ts @@ -1536,6 +1536,56 @@ describe("SorobanService leaderboard", () => { }); }); + it("getLeaderboard filters event-derived rows by a case-insensitive address search", async () => { + const otherUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 8)); + const { service, rpcServer } = makeService({ pool: false }); + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ + { + id: "factory-pool", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0", + minLockPeriod: 0, + totalLocked: "0", + totalUsers: 0, + isActive: true, + createdAt: 1, + }, + ]); + rpcServer.getLatestLedger.mockResolvedValue({ sequence: 200_000 }); + rpcServer.getEvents.mockResolvedValue({ + events: [ + makeContractEvent({ + action: "update_credits", + address: USER_PUBLIC_KEY, + value: { credits: 120 }, + }), + makeContractEvent({ + action: "update_credits", + address: otherUser, + value: 50, + }), + ], + }); + + const needle = USER_PUBLIC_KEY.slice(4, 14).toLowerCase(); + await expect(service.getLeaderboard(0, 10, "credits", needle)).resolves.toEqual({ + entries: [ + { + address: USER_PUBLIC_KEY, + totalCredits: 120, + totalStake: 0, + boostUtilization: null, + }, + ], + total: 1, + }); + + await expect( + service.getLeaderboard(0, 10, "credits", "not-a-real-address"), + ).resolves.toEqual({ entries: [], total: 0 }); + }); + it("fetchLeaderboardFromEvents returns an empty page without pool IDs and on RPC errors", async () => { const warnSpy = vi .spyOn(console, "warn") @@ -1629,6 +1679,33 @@ describe("SorobanService leaderboard", () => { ); }); + it("getLeaderboard forwards a search term as a query param to the API", async () => { + const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = + "https://leaderboard.example/rankings"; + vi.resetModules(); + const { SorobanService: ApiSorobanService } = await import("./soroban"); + const service = new ApiSorobanService(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ entries: [], total: 0 }), + } as Response); + + try { + await service.getLeaderboard(0, 10, "credits", "GABC123"); + } finally { + if (previousApi === undefined) + delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + else process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = previousApi; + vi.resetModules(); + } + + expect(fetchSpy).toHaveBeenCalledWith( + "https://leaderboard.example/rankings?offset=0&limit=10&sort=credits&search=GABC123", + { headers: { accept: "application/json" } }, + ); + }); + it("getLeaderboard falls back to event scanning when the API responds non-OK", async () => { const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index 0b75857..f20cd22 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -4,6 +4,7 @@ */ import { + Account, Contract, TransactionBuilder, BASE_FEE, @@ -1484,7 +1485,7 @@ export class SorobanService { async getPoolHistory( poolId: string, days: number = 7, - ): Promise<{ date: string; tvl: string; truncated?: boolean }> { + ): Promise<{ date: string; tvl: string; truncated?: boolean }[]> { try { const latest = await this.rpcServer.getLatestLedger(); // ~5 s per ledger; days * 86400 / 5 @@ -1627,26 +1628,29 @@ export class SorobanService { offset: number, limit: number, sortKey: LeaderboardSortKey = 'credits', + search?: string, ): Promise { if (LEADERBOARD_API_URL) { try { - return await this.fetchLeaderboardFromApi(offset, limit, sortKey); + return await this.fetchLeaderboardFromApi(offset, limit, sortKey, search); } catch (err) { console.warn('[SmartDrop] leaderboard API failed, falling back to event scan:', err); } } - return this.fetchLeaderboardFromEvents(offset, limit, sortKey); + return this.fetchLeaderboardFromEvents(offset, limit, sortKey, search); } private async fetchLeaderboardFromApi( offset: number, limit: number, sortKey: LeaderboardSortKey, + search?: string, ): Promise { const url = new URL(LEADERBOARD_API_URL); url.searchParams.set('offset', String(offset)); url.searchParams.set('limit', String(limit)); url.searchParams.set('sort', sortKey); + if (search) url.searchParams.set('search', search); const res = await fetch(url.toString(), { headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(`Leaderboard API responded ${res.status}`); @@ -1668,6 +1672,7 @@ export class SorobanService { offset: number, limit: number, sortKey: LeaderboardSortKey, + search?: string, ): Promise { const poolIds = await this.getLeaderboardPoolIds(); if (poolIds.length === 0) return { entries: [], total: 0 }; @@ -1735,7 +1740,8 @@ export class SorobanService { totalStake: Math.round(stake), boostUtilization: null, })) - .filter((e) => e.totalStake > 0 || e.totalCredits > 0); + .filter((e) => e.totalStake > 0 || e.totalCredits > 0) + .filter((e) => !search || e.address.toLowerCase().includes(search.toLowerCase())); all.sort((a, b) => sortKey === 'credits'