Skip to content

Commit decc2c3

Browse files
authored
Merge pull request #391 from bbjiggy/feat/sdk-fee-estimator-and-indexer-graphql
feat: SDK fee estimator and indexer GraphQL API layer
2 parents 1cec24c + 6590df7 commit decc2c3

11 files changed

Lines changed: 1500 additions & 4 deletions

File tree

indexer/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"better-sqlite3": "^11.0.0",
2121
"express": "^4.18.2",
2222
"express-rate-limit": "^8.5.2",
23+
"graphql": "^16.14.2",
24+
"graphql-yoga": "^5.21.2",
2325
"ws": "^8.16.0"
2426
},
2527
"devDependencies": {

indexer/src/api/graphql/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createYoga, createSchema } from 'graphql-yoga';
2+
import type Database from 'better-sqlite3';
3+
import type { Express } from 'express';
4+
import { typeDefs } from './schema.js';
5+
import { createResolvers } from './resolvers.js';
6+
7+
/**
8+
* Attach the GraphQL endpoint to an Express app.
9+
*
10+
* - Endpoint: `POST /graphql` (queries and mutations)
11+
* - GraphiQL playground: `GET /graphql` in development mode
12+
*
13+
* @param app - Express application instance
14+
* @param db - SQLite database (same instance used by REST routes)
15+
* @param dev - Enable the GraphiQL playground (default: NODE_ENV !== 'production')
16+
*/
17+
export function mountGraphQL(
18+
app: Express,
19+
db: Database.Database,
20+
dev = process.env['NODE_ENV'] !== 'production'
21+
): void {
22+
const schema = createSchema({
23+
typeDefs,
24+
resolvers: createResolvers(db),
25+
});
26+
27+
const yoga = createYoga({
28+
schema,
29+
graphiql: dev,
30+
logging: false,
31+
});
32+
33+
app.use('/graphql', yoga);
34+
}
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import Database from 'better-sqlite3';
3+
import { initializeSchema } from '../../db/schema.js';
4+
import { createResolvers } from './resolvers.js';
5+
import { clearStatsCache } from '../../services/statsService.js';
6+
7+
// ---------------------------------------------------------------------------
8+
// In-memory database setup
9+
// ---------------------------------------------------------------------------
10+
11+
let db: Database.Database;
12+
13+
beforeEach(() => {
14+
db = new Database(':memory:');
15+
initializeSchema(db);
16+
});
17+
18+
afterEach(() => {
19+
db.close();
20+
clearStatsCache();
21+
});
22+
23+
// Convenience getter — resolvers are pure functions over the DB
24+
function resolvers() {
25+
return createResolvers(db).Query;
26+
}
27+
28+
// ---------------------------------------------------------------------------
29+
// Seed helpers
30+
// ---------------------------------------------------------------------------
31+
32+
function seedInvoice(overrides: Partial<{
33+
id: number;
34+
freelancer: string;
35+
payer: string;
36+
token: string;
37+
amount: string;
38+
due_date: number;
39+
discount_rate: number;
40+
status: string;
41+
funder: string | null;
42+
funded_at: number | null;
43+
amount_funded: string;
44+
amount_paid: string;
45+
referral_code: string | null;
46+
submitter_reputation: number;
47+
created_at: number;
48+
}> = {}) {
49+
const now = Math.floor(Date.now() / 1000);
50+
const defaults = {
51+
id: 1,
52+
freelancer: 'GFREELANCER0000000000000000000000000000000000000000000000',
53+
payer: 'GPAYER000000000000000000000000000000000000000000000000000',
54+
token: 'CDTOKEN000000000000000000000000000000000000000000000000000',
55+
amount: '1000000',
56+
due_date: now + 86400 * 30,
57+
discount_rate: 300,
58+
status: 'Pending',
59+
funder: null,
60+
funded_at: null,
61+
amount_funded: '0',
62+
amount_paid: '0',
63+
referral_code: null,
64+
submitter_reputation: 50,
65+
created_at: now,
66+
};
67+
const row = { ...defaults, ...overrides };
68+
69+
db.prepare(`
70+
INSERT INTO invoices
71+
(id, freelancer, payer, token, amount, due_date, discount_rate, status,
72+
funder, funded_at, amount_funded, amount_paid, referral_code,
73+
submitter_reputation, created_at)
74+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
75+
`).run(
76+
row.id, row.freelancer, row.payer, row.token, row.amount,
77+
row.due_date, row.discount_rate, row.status, row.funder,
78+
row.funded_at, row.amount_funded, row.amount_paid,
79+
row.referral_code, row.submitter_reputation, row.created_at
80+
);
81+
82+
return row;
83+
}
84+
85+
function seedReputation(address: string, score = 80, invoicesPaid = 5, invoicesDefaulted = 0) {
86+
const now = Math.floor(Date.now() / 1000);
87+
db.prepare(`
88+
INSERT INTO reputation_updates
89+
(address, event_type, old_score, new_score, invoices_submitted,
90+
invoices_paid, invoices_defaulted, ledger, timestamp)
91+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
92+
`).run(address, 'reputation_updated', 0, score, invoicesPaid + invoicesDefaulted,
93+
invoicesPaid, invoicesDefaulted, 1000, now);
94+
}
95+
96+
// ---------------------------------------------------------------------------
97+
// invoice query
98+
// ---------------------------------------------------------------------------
99+
100+
describe("invoice", () => {
101+
it("returns the invoice when found", () => {
102+
seedInvoice({ id: 1 });
103+
const result = resolvers().invoice(null, { id: 1 });
104+
expect(result).not.toBeNull();
105+
expect(result!.id).toBe(1);
106+
expect(result!.status).toBe('Pending');
107+
});
108+
109+
it("returns null when invoice does not exist", () => {
110+
const result = resolvers().invoice(null, { id: 999 });
111+
expect(result).toBeNull();
112+
});
113+
114+
it("maps snake_case columns to camelCase fields", () => {
115+
const now = Math.floor(Date.now() / 1000);
116+
seedInvoice({ id: 2, due_date: now + 86400, created_at: now });
117+
const result = resolvers().invoice(null, { id: 2 });
118+
expect(result).toHaveProperty('dueDate');
119+
expect(result).toHaveProperty('createdAt');
120+
expect(result).toHaveProperty('amountFunded');
121+
expect(result).toHaveProperty('effectiveYieldBps');
122+
expect(result).toHaveProperty('remainingBalance');
123+
expect(result).toHaveProperty('daysUntilExpiry');
124+
});
125+
126+
it("computes remainingBalance as amountFunded - amountPaid", () => {
127+
seedInvoice({ id: 3, amount_funded: '1000000', amount_paid: '400000' });
128+
const result = resolvers().invoice(null, { id: 3 });
129+
expect(result!.remainingBalance).toBe('600000');
130+
});
131+
132+
it("clamps remainingBalance to '0' when fully paid", () => {
133+
seedInvoice({ id: 4, amount_funded: '500000', amount_paid: '600000' });
134+
const result = resolvers().invoice(null, { id: 4 });
135+
expect(result!.remainingBalance).toBe('0');
136+
});
137+
138+
it("returns 0 effectiveYieldBps when due date has passed", () => {
139+
const past = Math.floor(Date.now() / 1000) - 1;
140+
seedInvoice({ id: 5, due_date: past });
141+
const result = resolvers().invoice(null, { id: 5 });
142+
expect(result!.effectiveYieldBps).toBe(0);
143+
});
144+
});
145+
146+
// ---------------------------------------------------------------------------
147+
// invoices query
148+
// ---------------------------------------------------------------------------
149+
150+
describe("invoices", () => {
151+
beforeEach(() => {
152+
seedInvoice({ id: 1, status: 'Pending', token: 'USDC' });
153+
seedInvoice({ id: 2, status: 'Funded', token: 'USDC',
154+
freelancer: 'GFREELANCER1111111111111111111111111111111111111111111111' });
155+
seedInvoice({ id: 3, status: 'Paid', token: 'EURC',
156+
freelancer: 'GFREELANCER2222222222222222222222222222222222222222222222' });
157+
});
158+
159+
it("returns all invoices with default pagination", () => {
160+
const result = resolvers().invoices(null, {});
161+
expect(result.total).toBe(3);
162+
expect(result.invoices).toHaveLength(3);
163+
expect(result.page).toBe(1);
164+
expect(result.pageSize).toBe(20);
165+
});
166+
167+
it("filters by status", () => {
168+
const result = resolvers().invoices(null, { filter: { status: 'Pending' } });
169+
expect(result.total).toBe(1);
170+
expect(result.invoices[0].status).toBe('Pending');
171+
});
172+
173+
it("filters by token", () => {
174+
const result = resolvers().invoices(null, { filter: { token: 'EURC' } });
175+
expect(result.total).toBe(1);
176+
expect(result.invoices[0].token).toBe('EURC');
177+
});
178+
179+
it("filters by submitter (freelancer)", () => {
180+
const result = resolvers().invoices(null, {
181+
filter: { submitter: 'GFREELANCER2222222222222222222222222222222222222222222222' },
182+
});
183+
expect(result.total).toBe(1);
184+
expect(result.invoices[0].id).toBe(3);
185+
});
186+
187+
it("paginates correctly", () => {
188+
const result = resolvers().invoices(null, {
189+
pagination: { page: 1, pageSize: 2 },
190+
});
191+
expect(result.invoices).toHaveLength(2);
192+
expect(result.page).toBe(1);
193+
expect(result.pageSize).toBe(2);
194+
expect(result.total).toBe(3);
195+
});
196+
197+
it("returns second page", () => {
198+
const result = resolvers().invoices(null, {
199+
pagination: { page: 2, pageSize: 2 },
200+
});
201+
expect(result.invoices).toHaveLength(1);
202+
expect(result.page).toBe(2);
203+
});
204+
205+
it("caps pageSize at 100", () => {
206+
const result = resolvers().invoices(null, { pagination: { pageSize: 9999 } });
207+
expect(result.pageSize).toBe(100);
208+
});
209+
210+
it("combines multiple filters", () => {
211+
const result = resolvers().invoices(null, {
212+
filter: { status: 'Funded', token: 'USDC' },
213+
});
214+
expect(result.total).toBe(1);
215+
expect(result.invoices[0].id).toBe(2);
216+
});
217+
218+
it("returns empty list when no invoices match", () => {
219+
const result = resolvers().invoices(null, { filter: { status: 'Disputed' } });
220+
expect(result.total).toBe(0);
221+
expect(result.invoices).toHaveLength(0);
222+
});
223+
});
224+
225+
// ---------------------------------------------------------------------------
226+
// reputation query
227+
// ---------------------------------------------------------------------------
228+
229+
describe("reputation", () => {
230+
const ADDR = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
231+
232+
it("returns zero profile for unknown address", () => {
233+
const result = resolvers().reputation(null, { address: ADDR });
234+
expect(result.score).toBe(0);
235+
expect(result.invoicesPaid).toBe(0);
236+
expect(result.invoicesDefaulted).toBe(0);
237+
expect(result.history).toHaveLength(0);
238+
});
239+
240+
it("returns the latest reputation score", () => {
241+
seedReputation(ADDR, 80, 5, 1);
242+
const result = resolvers().reputation(null, { address: ADDR });
243+
expect(result.score).toBe(80);
244+
expect(result.invoicesPaid).toBe(5);
245+
expect(result.invoicesDefaulted).toBe(1);
246+
});
247+
248+
it("includes history entries", () => {
249+
seedReputation(ADDR, 60, 3, 0);
250+
const result = resolvers().reputation(null, { address: ADDR });
251+
expect(result.history).toHaveLength(1);
252+
expect(result.history[0].score).toBe(60);
253+
expect(result.history[0].eventType).toBe('reputation_updated');
254+
});
255+
256+
it("echoes the queried address", () => {
257+
const result = resolvers().reputation(null, { address: ADDR });
258+
expect(result.address).toBe(ADDR);
259+
});
260+
});
261+
262+
// ---------------------------------------------------------------------------
263+
// leaderboard query
264+
// ---------------------------------------------------------------------------
265+
266+
describe("leaderboard", () => {
267+
it("returns an empty array when no reputations exist", () => {
268+
const result = resolvers().leaderboard(null, {});
269+
expect(result).toEqual([]);
270+
});
271+
272+
it("returns entries ranked by score", () => {
273+
const A = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
274+
const B = 'GBUVRIIBMHKC4REQCA754YCMQZYS3CJZQ5CKEKV2OHZ6C3XXZR3KFMK';
275+
seedReputation(A, 90, 10, 0);
276+
seedReputation(B, 50, 3, 1);
277+
278+
const result = resolvers().leaderboard(null, { limit: 10 });
279+
expect(result).toHaveLength(2);
280+
expect(result[0].score).toBeGreaterThanOrEqual(result[1].score);
281+
});
282+
283+
it("respects the limit parameter", () => {
284+
const addresses = [
285+
'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN',
286+
'GBUVRIIBMHKC4REQCA754YCMQZYS3CJZQ5CKEKV2OHZ6C3XXZR3KFMK',
287+
'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZM81ENQHQT36IOESXUQA',
288+
];
289+
addresses.forEach((a, i) => seedReputation(a, (i + 1) * 10, i, 0));
290+
291+
const result = resolvers().leaderboard(null, { limit: 2 });
292+
expect(result.length).toBeLessThanOrEqual(2);
293+
});
294+
});
295+
296+
// ---------------------------------------------------------------------------
297+
// stats query
298+
// ---------------------------------------------------------------------------
299+
300+
describe("stats", () => {
301+
it("returns zero stats on an empty database", () => {
302+
const result = resolvers().stats(null, undefined);
303+
expect(result.totalInvoices).toBe(0);
304+
expect(result.totalFunded).toBe(0);
305+
expect(result.totalPaid).toBe(0);
306+
});
307+
308+
it("counts invoices by status correctly", () => {
309+
seedInvoice({ id: 1, status: 'Pending' });
310+
seedInvoice({ id: 2, status: 'Paid',
311+
freelancer: 'GFREELANCER1111111111111111111111111111111111111111111111' });
312+
seedInvoice({ id: 3, status: 'Funded',
313+
freelancer: 'GFREELANCER2222222222222222222222222222222222222222222222' });
314+
315+
const result = resolvers().stats(null, undefined);
316+
expect(result.totalInvoices).toBe(3);
317+
expect(result.totalPaid).toBe(1);
318+
});
319+
});
320+
321+
// ---------------------------------------------------------------------------
322+
// governanceProposals query
323+
// ---------------------------------------------------------------------------
324+
325+
describe("governanceProposals", () => {
326+
it("returns an empty array (proposals are on-chain only)", () => {
327+
const result = resolvers().governanceProposals(null, undefined);
328+
expect(result).toEqual([]);
329+
});
330+
});

0 commit comments

Comments
 (0)