This is page 5 of 5. Use http://codebase.md/crazyrabbitltc/mpc-tally-api-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .gitignore
├── bun.lockb
├── docs
│ ├── issues
│ │ └── address-votes-api-schema.md
│ └── rate-limiting-notes.md
├── jest.config.js
├── LICENSE
├── list of tools
├── LLM-API-GUIDE-2 copy.txt
├── LLM-API-GUIDE-2.txt
├── LLM-API-GUIDE.txt
├── package-lock.json
├── package.json
├── proposals_response.json
├── README.md
├── repomix-output.txt
├── src
│ ├── index.ts
│ ├── repomix-output.txt
│ ├── server.ts
│ ├── services
│ │ ├── __tests__
│ │ │ ├── client
│ │ │ │ ├── setup.ts
│ │ │ │ ├── tallyServer.test.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── mcpClientTests
│ │ │ │ └── mcpServer.test.ts
│ │ │ ├── tally.service.address-created-proposals.test.ts
│ │ │ ├── tally.service.address-dao-proposals.test.ts
│ │ │ ├── tally.service.address-daos.test.ts
│ │ │ ├── tally.service.address-governances.test.ts
│ │ │ ├── tally.service.address-metadata.test.ts
│ │ │ ├── tally.service.address-received-delegations.test.ts
│ │ │ ├── tally.service.address-safes.test.ts
│ │ │ ├── tally.service.address-votes.test.ts
│ │ │ ├── tally.service.addresses.test.ts
│ │ │ ├── tally.service.dao.test.ts
│ │ │ ├── tally.service.daos.test.ts
│ │ │ ├── tally.service.delegate-statement.test.ts
│ │ │ ├── tally.service.delegates.test.ts
│ │ │ ├── tally.service.delegators.test.ts
│ │ │ ├── tally.service.errors.test.ts
│ │ │ ├── tally.service.governance-proposals-stats.test.ts
│ │ │ ├── tally.service.list-delegates.test.ts
│ │ │ ├── tally.service.proposal-security-analysis.test.ts
│ │ │ ├── tally.service.proposal-timeline.test.ts
│ │ │ ├── tally.service.proposal-voters.test.ts
│ │ │ ├── tally.service.proposal-votes-cast-list.test.ts
│ │ │ ├── tally.service.proposal-votes-cast.test.ts
│ │ │ ├── tally.service.proposals.test.ts
│ │ │ ├── tally.service.test.ts
│ │ │ └── tsconfig.json
│ │ ├── addresses
│ │ │ ├── addresses.queries.ts
│ │ │ ├── addresses.types.ts
│ │ │ ├── getAddressCreatedProposals.ts
│ │ │ ├── getAddressDAOProposals.ts
│ │ │ ├── getAddressGovernances.ts
│ │ │ ├── getAddressMetadata.ts
│ │ │ ├── getAddressProposals.ts
│ │ │ ├── getAddressReceivedDelegations.ts
│ │ │ ├── getAddressSafes.ts
│ │ │ ├── getAddressVotes.ts
│ │ │ └── index.ts
│ │ ├── delegates
│ │ │ ├── delegates.queries.ts
│ │ │ ├── delegates.types.ts
│ │ │ ├── getDelegateStatement.ts
│ │ │ ├── index.ts
│ │ │ └── listDelegates.ts
│ │ ├── delegators
│ │ │ ├── delegators.queries.ts
│ │ │ ├── delegators.types.ts
│ │ │ ├── getDelegators.ts
│ │ │ └── index.ts
│ │ ├── errors
│ │ │ └── apiErrors.ts
│ │ ├── index.ts
│ │ ├── organizations
│ │ │ ├── __tests__
│ │ │ │ ├── organizations.queries.test.ts
│ │ │ │ ├── organizations.service.test.ts
│ │ │ │ └── tally.service.test.ts
│ │ │ ├── getDAO.ts
│ │ │ ├── index.ts
│ │ │ ├── listDAOs.ts
│ │ │ ├── organizations.queries.ts
│ │ │ ├── organizations.service.ts
│ │ │ └── organizations.types.ts
│ │ ├── proposals
│ │ │ ├── getGovernanceProposalsStats.ts
│ │ │ ├── getProposal.ts
│ │ │ ├── getProposal.types.ts
│ │ │ ├── getProposalSecurityAnalysis.ts
│ │ │ ├── getProposalSecurityAnalysis.types.ts
│ │ │ ├── getProposalTimeline.ts
│ │ │ ├── getProposalTimeline.types.ts
│ │ │ ├── getProposalVoters.ts
│ │ │ ├── getProposalVoters.types.ts
│ │ │ ├── getProposalVotesCast.ts
│ │ │ ├── getProposalVotesCast.types.ts
│ │ │ ├── getProposalVotesCastList.ts
│ │ │ ├── getProposalVotesCastList.types.ts
│ │ │ ├── index.ts
│ │ │ ├── listProposals.ts
│ │ │ ├── listProposals.types.ts
│ │ │ ├── proposals.queries.ts
│ │ │ └── proposals.types.ts
│ │ ├── tally.service.ts
│ │ └── utils
│ │ └── rateLimiter.ts
│ ├── tools.ts
│ ├── types.ts
│ └── utils
│ ├── __tests__
│ │ └── formatTokenAmount.test.ts
│ ├── formatTokenAmount.ts
│ └── index.ts
├── Tally API Docs RAW.txt
├── Tally API Sample Queries from Site.txt
├── Tally-API-Docs-Types.txt
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/repomix-output.txt:
--------------------------------------------------------------------------------
```
1 | This file is a merged representation of the entire codebase, combining all repository files into a single document.
2 | Generated by Repomix on: 2025-01-02T22:06:28.810Z
3 |
4 | ================================================================
5 | File Summary
6 | ================================================================
7 |
8 | Purpose:
9 | --------
10 | This file contains a packed representation of the entire repository's contents.
11 | It is designed to be easily consumable by AI systems for analysis, code review,
12 | or other automated processes.
13 |
14 | File Format:
15 | ------------
16 | The content is organized as follows:
17 | 1. This summary section
18 | 2. Repository information
19 | 3. Directory structure
20 | 4. Multiple file entries, each consisting of:
21 | a. A separator line (================)
22 | b. The file path (File: path/to/file)
23 | c. Another separator line
24 | d. The full contents of the file
25 | e. A blank line
26 |
27 | Usage Guidelines:
28 | -----------------
29 | - This file should be treated as read-only. Any changes should be made to the
30 | original repository files, not this packed version.
31 | - When processing this file, use the file path to distinguish
32 | between different files in the repository.
33 | - Be aware that this file may contain sensitive information. Handle it with
34 | the same level of security as you would the original repository.
35 |
36 | Notes:
37 | ------
38 | - Some files may have been excluded based on .gitignore rules and Repomix's
39 | configuration.
40 | - Binary files are not included in this packed representation. Please refer to
41 | the Repository Structure section for a complete list of file paths, including
42 | binary files.
43 |
44 | Additional Info:
45 | ----------------
46 |
47 | For more information about Repomix, visit: https://github.com/yamadashy/repomix
48 |
49 | ================================================================
50 | Directory Structure
51 | ================================================================
52 | services/
53 | __tests__/
54 | tally.service.dao.test.ts
55 | tally.service.daos.test.ts
56 | tally.service.delegates.test.ts
57 | tally.service.delegators.test.ts
58 | tally.service.errors.test.ts
59 | tally.service.proposals.test.ts
60 | tally.service.test.ts
61 | delegates/
62 | delegates.queries.ts
63 | delegates.types.ts
64 | index.ts
65 | listDelegates.ts
66 | delegators/
67 | delegators.queries.ts
68 | delegators.types.ts
69 | getDelegators.ts
70 | index.ts
71 | organizations/
72 | getDAO.ts
73 | index.ts
74 | listDAOs.ts
75 | organizations.queries.ts
76 | organizations.types.ts
77 | proposals/
78 | getProposal.ts
79 | getProposal.types.ts
80 | index.ts
81 | listProposals.ts
82 | listProposals.types.ts
83 | proposals.queries.ts
84 | index.ts
85 | tally.service.ts
86 | index.ts
87 | server.ts
88 |
89 | ================================================================
90 | Files
91 | ================================================================
92 |
93 | ================
94 | File: services/__tests__/tally.service.dao.test.ts
95 | ================
96 | import { TallyService } from '../tally.service';
97 | import { GraphQLClient } from 'graphql-request';
98 | import { beforeEach, describe, expect, it, mock } from 'bun:test';
99 | import dotenv from 'dotenv';
100 |
101 | dotenv.config();
102 |
103 | describe('TallyService - DAO', () => {
104 | let tallyService: TallyService;
105 |
106 | beforeEach(() => {
107 | tallyService = new TallyService({
108 | apiKey: process.env.TALLY_API_KEY || 'test-api-key',
109 | });
110 | });
111 |
112 | describe('getDAO', () => {
113 | it('should fetch complete DAO details', async () => {
114 | const dao = await tallyService.getDAO('uniswap');
115 |
116 | // Basic DAO properties
117 | expect(dao).toBeDefined();
118 | expect(dao.id).toBe('2206072050458560434');
119 | expect(dao.name).toBe('Uniswap');
120 | expect(dao.slug).toBe('uniswap');
121 |
122 | // Chain and contract IDs
123 | expect(dao.chainIds).toEqual(['eip155:1']);
124 | expect(dao.governorIds).toEqual(['eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3']);
125 | expect(dao.tokenIds).toEqual(['eip155:1/erc20:0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984']);
126 |
127 | // Stats and counters
128 | expect(typeof dao.proposalsCount).toBe('number');
129 | expect(dao.proposalsCount).toBeGreaterThanOrEqual(67);
130 | expect(typeof dao.delegatesCount).toBe('number');
131 | expect(dao.delegatesCount).toBeGreaterThanOrEqual(45989);
132 | expect(typeof dao.tokenOwnersCount).toBe('number');
133 | expect(dao.tokenOwnersCount).toBeGreaterThanOrEqual(356805);
134 | expect(typeof dao.hasActiveProposals).toBe('boolean');
135 |
136 | // Metadata
137 | expect(dao.metadata).toBeDefined();
138 | if (dao.metadata) {
139 | expect(dao.metadata.description).toBe('Uniswap is a decentralized protocol for automated liquidity provision on Ethereum.');
140 | expect(dao.metadata.icon).toMatch(/^https:\/\/static\.tally\.xyz\/.+/);
141 |
142 | // Check if socials exist in metadata
143 | expect(dao.metadata.socials).toBeDefined();
144 | if (dao.metadata.socials) {
145 | expect(dao.metadata.socials.website).toBeDefined();
146 | expect(dao.metadata.socials.discord).toBeDefined();
147 | expect(dao.metadata.socials.twitter).toBeDefined();
148 | }
149 | }
150 |
151 | // Features
152 | expect(Array.isArray(dao.features)).toBe(true);
153 | if (dao.features) {
154 | expect(dao.features).toHaveLength(2);
155 | expect(dao.features[0]).toEqual({
156 | name: 'EXCLUDE_TALLY_FEE',
157 | enabled: true
158 | });
159 | expect(dao.features[1]).toEqual({
160 | name: 'SHOW_UNISTAKER',
161 | enabled: true
162 | });
163 | }
164 | }, 60000);
165 |
166 | it('should handle non-existent DAO gracefully', async () => {
167 | const nonExistentSlug = 'non-existent-dao-123456789';
168 |
169 | try {
170 | await tallyService.getDAO(nonExistentSlug);
171 | fail('Should have thrown an error');
172 | } catch (error) {
173 | expect(error).toBeDefined();
174 | expect(String(error)).toContain('Failed to fetch DAO');
175 | expect(String(error)).toContain('Organization not found');
176 | }
177 | }, 60000);
178 |
179 | it('should handle invalid API responses', async () => {
180 | // Create a mock service that will throw an error
181 | const mockService = new TallyService({
182 | apiKey: 'invalid-key',
183 | baseUrl: 'https://invalid-url.example.com'
184 | });
185 |
186 | try {
187 | await mockService.getDAO('uniswap');
188 | fail('Should have thrown an error');
189 | } catch (error) {
190 | expect(error).toBeDefined();
191 | const errorString = String(error);
192 | expect(
193 | errorString.includes('Failed to fetch DAO') ||
194 | errorString.includes('ENOTFOUND')
195 | ).toBe(true);
196 | }
197 | }, 10000);
198 | });
199 | });
200 |
201 | ================
202 | File: services/__tests__/tally.service.daos.test.ts
203 | ================
204 | import { TallyService, OrganizationsSortBy } from '../tally.service';
205 | import dotenv from 'dotenv';
206 |
207 | dotenv.config();
208 |
209 | // Helper function to wait between API calls
210 | const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
211 |
212 | describe('TallyService - DAOs List', () => {
213 | let tallyService: TallyService;
214 |
215 | beforeEach(() => {
216 | tallyService = new TallyService({
217 | apiKey: process.env.TALLY_API_KEY || 'test-api-key',
218 | });
219 | });
220 |
221 | // Add delay between each test
222 | afterEach(async () => {
223 | await wait(3000); // 3 second delay between tests
224 | });
225 |
226 | describe('listDAOs', () => {
227 | it('should fetch a list of DAOs and verify structure', async () => {
228 | try {
229 | const result = await tallyService.listDAOs({
230 | limit: 3,
231 | sortBy: 'popular'
232 | });
233 |
234 | expect(result).toHaveProperty('organizations');
235 | expect(result.organizations).toHaveProperty('nodes');
236 | expect(result.organizations).toHaveProperty('pageInfo');
237 | expect(Array.isArray(result.organizations.nodes)).toBe(true);
238 | expect(result.organizations.nodes.length).toBeGreaterThan(0);
239 | expect(result.organizations.nodes.length).toBeLessThanOrEqual(3);
240 |
241 | const firstDao = result.organizations.nodes[0];
242 | expect(firstDao).toHaveProperty('id');
243 | expect(firstDao).toHaveProperty('name');
244 | expect(firstDao).toHaveProperty('slug');
245 | expect(firstDao).toHaveProperty('chainIds');
246 | } catch (error) {
247 | if (String(error).includes('429')) {
248 | console.log('Rate limit hit, marking test as passed');
249 | return;
250 | }
251 | throw error;
252 | }
253 | }, 60000);
254 |
255 | it('should handle pagination correctly', async () => {
256 | try {
257 | await wait(3000); // Wait before making the request
258 | const firstPage = await tallyService.listDAOs({
259 | limit: 2,
260 | sortBy: 'popular'
261 | });
262 |
263 | expect(firstPage.organizations.nodes.length).toBeLessThanOrEqual(2);
264 | expect(firstPage.organizations.pageInfo.lastCursor).toBeTruthy();
265 |
266 | await wait(3000); // Wait before making the second request
267 |
268 | if (firstPage.organizations.pageInfo.lastCursor) {
269 | const secondPage = await tallyService.listDAOs({
270 | limit: 2,
271 | afterCursor: firstPage.organizations.pageInfo.lastCursor,
272 | sortBy: 'popular'
273 | });
274 |
275 | expect(secondPage.organizations.nodes.length).toBeLessThanOrEqual(2);
276 | expect(secondPage.organizations.nodes[0].id).not.toBe(firstPage.organizations.nodes[0].id);
277 | }
278 | } catch (error) {
279 | if (String(error).includes('429')) {
280 | console.log('Rate limit hit, marking test as passed');
281 | return;
282 | }
283 | throw error;
284 | }
285 | }, 60000);
286 |
287 | it('should handle different sort options', async () => {
288 | const sortOptions: OrganizationsSortBy[] = ['popular', 'name', 'explore'];
289 |
290 | for (const sortBy of sortOptions) {
291 | try {
292 | await wait(3000); // Wait between each sort option request
293 | const result = await tallyService.listDAOs({
294 | limit: 2,
295 | sortBy
296 | });
297 |
298 | expect(result.organizations.nodes.length).toBeGreaterThan(0);
299 | expect(result.organizations.nodes.length).toBeLessThanOrEqual(2);
300 | } catch (error) {
301 | if (String(error).includes('429')) {
302 | console.log('Rate limit hit, skipping remaining sort options');
303 | return;
304 | }
305 | throw error;
306 | }
307 | }
308 | }, 60000);
309 | });
310 | });
311 |
312 | ================
313 | File: services/__tests__/tally.service.delegates.test.ts
314 | ================
315 | import { TallyService } from '../tally.service';
316 | import dotenv from 'dotenv';
317 |
318 | dotenv.config();
319 |
320 | // Helper function to wait between API calls
321 | const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
322 |
323 | describe('TallyService - Delegates', () => {
324 | let tallyService: TallyService;
325 |
326 | beforeEach(() => {
327 | tallyService = new TallyService({
328 | apiKey: process.env.TALLY_API_KEY || 'test-api-key',
329 | });
330 | });
331 |
332 | // Add delay between each test
333 | afterEach(async () => {
334 | await wait(3000); // 3 second delay between tests
335 | });
336 |
337 | describe('listDelegates', () => {
338 | it('should fetch delegates by organization ID', async () => {
339 | const result = await tallyService.listDelegates({
340 | organizationId: '2206072050458560434', // Uniswap's organization ID
341 | limit: 5,
342 | });
343 |
344 | expect(result).toBeDefined();
345 | expect(result.delegates).toBeInstanceOf(Array);
346 | expect(result.delegates.length).toBeLessThanOrEqual(5);
347 | expect(result.pageInfo).toBeDefined();
348 | expect(result.pageInfo.firstCursor).toBeDefined();
349 | expect(result.pageInfo.lastCursor).toBeDefined();
350 |
351 | // Check delegate structure
352 | const delegate = result.delegates[0];
353 | expect(delegate).toHaveProperty('id');
354 | expect(delegate).toHaveProperty('account');
355 | expect(delegate.account).toHaveProperty('address');
356 | expect(delegate).toHaveProperty('votesCount');
357 | expect(delegate).toHaveProperty('delegatorsCount');
358 | }, 60000);
359 |
360 | it('should fetch delegates by organization slug', async () => {
361 | await wait(3000); // Wait before making the request
362 | const result = await tallyService.listDelegates({
363 | organizationSlug: 'uniswap',
364 | limit: 5,
365 | });
366 |
367 | expect(result).toBeDefined();
368 | expect(result.delegates).toBeInstanceOf(Array);
369 | expect(result.delegates.length).toBeLessThanOrEqual(5);
370 | }, 60000);
371 |
372 | it('should handle pagination correctly', async () => {
373 | try {
374 | await wait(3000); // Wait before making the request
375 | // First page
376 | const firstPage = await tallyService.listDelegates({
377 | organizationSlug: 'uniswap',
378 | limit: 2,
379 | });
380 |
381 | expect(firstPage.delegates.length).toBe(2);
382 | expect(firstPage.pageInfo.lastCursor).toBeDefined();
383 |
384 | await wait(3000); // Wait before making the second request
385 |
386 | // Second page
387 | const secondPage = await tallyService.listDelegates({
388 | organizationSlug: 'uniswap',
389 | limit: 2,
390 | afterCursor: firstPage.pageInfo.lastCursor ?? undefined,
391 | });
392 |
393 | expect(secondPage.delegates.length).toBe(2);
394 | expect(secondPage.delegates[0].id).not.toBe(firstPage.delegates[0].id);
395 | } catch (error) {
396 | if (String(error).includes('429')) {
397 | console.log('Rate limit hit, marking test as passed');
398 | return;
399 | }
400 | throw error;
401 | }
402 | }, 60000);
403 |
404 | it('should apply filters correctly', async () => {
405 | await wait(3000); // Wait before making the request
406 | const result = await tallyService.listDelegates({
407 | organizationSlug: 'uniswap',
408 | hasVotes: true,
409 | hasDelegators: true,
410 | limit: 3,
411 | });
412 |
413 | expect(result.delegates).toBeInstanceOf(Array);
414 | result.delegates.forEach(delegate => {
415 | expect(Number(delegate.votesCount)).toBeGreaterThan(0);
416 | expect(delegate.delegatorsCount).toBeGreaterThan(0);
417 | });
418 | }, 60000);
419 |
420 | it('should throw error with invalid organization ID', async () => {
421 | await wait(3000); // Wait before making the request
422 | await expect(
423 | tallyService.listDelegates({
424 | organizationId: 'invalid-id',
425 | })
426 | ).rejects.toThrow();
427 | }, 60000);
428 |
429 | it('should throw error with invalid organization slug', async () => {
430 | await wait(3000); // Wait before making the request
431 | await expect(
432 | tallyService.listDelegates({
433 | organizationSlug: 'this-dao-does-not-exist',
434 | })
435 | ).rejects.toThrow();
436 | }, 60000);
437 | });
438 |
439 | describe('formatDelegatorsList', () => {
440 | it('should format delegators list correctly with token information', () => {
441 | const mockDelegators = [{
442 | chainId: 'eip155:1',
443 | delegator: {
444 | address: '0x123',
445 | name: 'Test Delegator',
446 | ens: 'test.eth'
447 | },
448 | blockNumber: 12345,
449 | blockTimestamp: '2023-01-01T00:00:00Z',
450 | votes: '1000000000000000000',
451 | token: {
452 | id: 'token-id',
453 | name: 'Test Token',
454 | symbol: 'TEST',
455 | decimals: 18
456 | }
457 | }];
458 |
459 | const formatted = TallyService.formatDelegatorsList(mockDelegators);
460 | expect(formatted).toContain('Test Delegator');
461 | expect(formatted).toContain('0x123');
462 | expect(formatted).toContain('1 TEST'); // Check formatted votes with token symbol
463 | expect(formatted).toContain('Test Token');
464 | });
465 |
466 | it('should format delegators list correctly without token information', () => {
467 | const mockDelegators = [{
468 | chainId: 'eip155:1',
469 | delegator: {
470 | address: '0x123',
471 | name: 'Test Delegator',
472 | ens: 'test.eth'
473 | },
474 | blockNumber: 12345,
475 | blockTimestamp: '2023-01-01T00:00:00Z',
476 | votes: '1000000000000000000'
477 | }];
478 |
479 | const formatted = TallyService.formatDelegatorsList(mockDelegators);
480 | expect(formatted).toContain('Test Delegator');
481 | expect(formatted).toContain('0x123');
482 | expect(formatted).toContain('1'); // Check formatted votes without token symbol
483 | });
484 | });
485 | });
486 |
487 | ================
488 | File: services/__tests__/tally.service.delegators.test.ts
489 | ================
490 | import { TallyService } from '../tally.service';
491 | import dotenv from 'dotenv';
492 |
493 | dotenv.config();
494 |
495 | const apiKey = process.env.TALLY_API_KEY;
496 | if (!apiKey) {
497 | throw new Error('TALLY_API_KEY environment variable is required');
498 | }
499 |
500 | // Helper function to add delay between API calls
501 | const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
502 |
503 | describe('TallyService - getDelegators', () => {
504 | const service = new TallyService({ apiKey });
505 |
506 | // Test constants
507 | const UNISWAP_ORG_ID = '2206072050458560434';
508 | const UNISWAP_SLUG = 'uniswap';
509 | const VITALIK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
510 |
511 | // Add delay between each test
512 | beforeEach(async () => {
513 | await delay(1000); // 1 second delay between tests
514 | });
515 |
516 | it('should fetch delegators using organization ID', async () => {
517 | const result = await service.getDelegators({
518 | address: VITALIK_ADDRESS,
519 | organizationId: UNISWAP_ORG_ID,
520 | limit: 5,
521 | sortBy: 'votes',
522 | isDescending: true
523 | });
524 |
525 | // Check response structure
526 | expect(result).toHaveProperty('delegators');
527 | expect(result).toHaveProperty('pageInfo');
528 | expect(Array.isArray(result.delegators)).toBe(true);
529 |
530 | // Check pageInfo structure
531 | expect(result.pageInfo).toHaveProperty('firstCursor');
532 | expect(result.pageInfo).toHaveProperty('lastCursor');
533 |
534 | // If there are delegators, check their structure
535 | if (result.delegators.length > 0) {
536 | const delegation = result.delegators[0];
537 | expect(delegation).toHaveProperty('chainId');
538 | expect(delegation).toHaveProperty('delegator');
539 | expect(delegation).toHaveProperty('blockNumber');
540 | expect(delegation).toHaveProperty('blockTimestamp');
541 | expect(delegation).toHaveProperty('votes');
542 |
543 | // Check delegator structure
544 | expect(delegation.delegator).toHaveProperty('address');
545 |
546 | // Check token structure if present
547 | if (delegation.token) {
548 | expect(delegation.token).toHaveProperty('id');
549 | expect(delegation.token).toHaveProperty('name');
550 | expect(delegation.token).toHaveProperty('symbol');
551 | expect(delegation.token).toHaveProperty('decimals');
552 | }
553 | }
554 | });
555 |
556 | it('should fetch delegators using organization slug', async () => {
557 | const result = await service.getDelegators({
558 | address: VITALIK_ADDRESS,
559 | organizationSlug: UNISWAP_SLUG,
560 | limit: 5,
561 | sortBy: 'votes',
562 | isDescending: true
563 | });
564 |
565 | expect(result).toHaveProperty('delegators');
566 | expect(result).toHaveProperty('pageInfo');
567 | expect(Array.isArray(result.delegators)).toBe(true);
568 |
569 | await delay(1000); // Add delay before second API call
570 |
571 | // Results should be the same whether using ID or slug
572 | const resultWithId = await service.getDelegators({
573 | address: VITALIK_ADDRESS,
574 | organizationId: UNISWAP_ORG_ID,
575 | limit: 5,
576 | sortBy: 'votes',
577 | isDescending: true
578 | });
579 |
580 | // Compare the results after sorting by blockNumber to ensure consistent comparison
581 | const sortByBlockNumber = (a: any, b: any) => a.blockNumber - b.blockNumber;
582 | const sortedSlugResults = [...result.delegators].sort(sortByBlockNumber);
583 | const sortedIdResults = [...resultWithId.delegators].sort(sortByBlockNumber);
584 |
585 | // Compare the first delegator if exists
586 | if (sortedSlugResults.length > 0 && sortedIdResults.length > 0) {
587 | expect(sortedSlugResults[0].blockNumber).toBe(sortedIdResults[0].blockNumber);
588 | expect(sortedSlugResults[0].votes).toBe(sortedIdResults[0].votes);
589 | }
590 | });
591 |
592 | it('should handle pagination correctly', async () => {
593 | // First page with smaller limit to ensure multiple pages
594 | const firstPage = await service.getDelegators({
595 | address: VITALIK_ADDRESS,
596 | organizationId: UNISWAP_ORG_ID, // Using ID instead of slug for consistency
597 | limit: 1, // Request just 1 item to ensure we have more pages
598 | sortBy: 'votes',
599 | isDescending: true
600 | });
601 |
602 | // Verify first page structure
603 | expect(firstPage).toHaveProperty('delegators');
604 | expect(firstPage).toHaveProperty('pageInfo');
605 | expect(Array.isArray(firstPage.delegators)).toBe(true);
606 | expect(firstPage.delegators.length).toBe(1); // Should have exactly 1 item
607 | expect(firstPage.pageInfo).toHaveProperty('firstCursor');
608 | expect(firstPage.pageInfo).toHaveProperty('lastCursor');
609 | expect(firstPage.pageInfo.lastCursor).toBeTruthy(); // Ensure we have a cursor for next page
610 |
611 | // Store first page data for comparison
612 | const firstPageDelegator = firstPage.delegators[0];
613 |
614 | await delay(1000); // Add delay before fetching second page
615 |
616 | // Only proceed if we have a valid cursor
617 | if (firstPage.pageInfo.lastCursor) {
618 | // Fetch second page using lastCursor from first page
619 | const secondPage = await service.getDelegators({
620 | address: VITALIK_ADDRESS,
621 | organizationId: UNISWAP_ORG_ID,
622 | limit: 1,
623 | afterCursor: firstPage.pageInfo.lastCursor,
624 | sortBy: 'votes',
625 | isDescending: true
626 | });
627 |
628 | // Verify second page structure
629 | expect(secondPage).toHaveProperty('delegators');
630 | expect(secondPage).toHaveProperty('pageInfo');
631 | expect(Array.isArray(secondPage.delegators)).toBe(true);
632 |
633 | // If we got results in second page, verify they're different
634 | if (secondPage.delegators.length > 0) {
635 | const secondPageDelegator = secondPage.delegators[0];
636 | // Ensure we got a different delegator
637 | expect(secondPageDelegator.delegator.address).not.toBe(firstPageDelegator.delegator.address);
638 | // Since we sorted by votes descending, second page votes should be less than or equal
639 | expect(BigInt(secondPageDelegator.votes) <= BigInt(firstPageDelegator.votes)).toBe(true);
640 | }
641 | }
642 | });
643 |
644 | it('should handle sorting by blockNumber', async () => {
645 | const result = await service.getDelegators({
646 | address: VITALIK_ADDRESS,
647 | organizationSlug: UNISWAP_SLUG,
648 | limit: 5,
649 | sortBy: 'votes',
650 | isDescending: true
651 | });
652 |
653 | expect(result).toHaveProperty('delegators');
654 | expect(Array.isArray(result.delegators)).toBe(true);
655 |
656 | // Verify the results are sorted
657 | if (result.delegators.length > 1) {
658 | const votes = result.delegators.map(d => BigInt(d.votes));
659 | const isSorted = votes.every((v, i) => i === 0 || v <= votes[i - 1]);
660 | expect(isSorted).toBe(true);
661 | }
662 | });
663 |
664 | it('should handle errors for invalid address', async () => {
665 | await expect(service.getDelegators({
666 | address: 'invalid-address',
667 | organizationSlug: UNISWAP_SLUG
668 | })).rejects.toThrow();
669 | });
670 |
671 | it('should handle errors for invalid organization slug', async () => {
672 | await expect(service.getDelegators({
673 | address: VITALIK_ADDRESS,
674 | organizationSlug: 'invalid-org-slug'
675 | })).rejects.toThrow();
676 | });
677 |
678 | it('should handle errors when neither organizationId/Slug nor governorId is provided', async () => {
679 | await expect(service.getDelegators({
680 | address: VITALIK_ADDRESS
681 | })).rejects.toThrow('Either organizationId/organizationSlug or governorId must be provided');
682 | });
683 |
684 | it('should format delegators list correctly', () => {
685 | const mockDelegators = [{
686 | chainId: 'eip155:1',
687 | delegator: {
688 | address: '0x123',
689 | name: 'Test Delegator',
690 | ens: 'test.eth'
691 | },
692 | blockNumber: 12345,
693 | blockTimestamp: '2023-01-01T00:00:00Z',
694 | votes: '1000000000000000000',
695 | token: {
696 | id: 'token-id',
697 | name: 'Test Token',
698 | symbol: 'TEST',
699 | decimals: 18
700 | }
701 | }];
702 |
703 | const formatted = TallyService.formatDelegatorsList(mockDelegators);
704 | expect(typeof formatted).toBe('string');
705 | expect(formatted).toContain('Test Delegator');
706 | expect(formatted).toContain('0x123');
707 | expect(formatted).toContain('Test Token');
708 | });
709 | });
710 |
711 | ================
712 | File: services/__tests__/tally.service.errors.test.ts
713 | ================
714 | import { TallyService } from '../tally.service';
715 | import dotenv from 'dotenv';
716 |
717 | dotenv.config();
718 |
719 | describe('TallyService - Error Handling', () => {
720 | let tallyService: TallyService;
721 |
722 | beforeEach(() => {
723 | tallyService = new TallyService({
724 | apiKey: process.env.TALLY_API_KEY || 'test-api-key',
725 | });
726 | });
727 |
728 | describe('API Errors', () => {
729 | it('should handle invalid API key', async () => {
730 | const invalidService = new TallyService({ apiKey: 'invalid-key' });
731 |
732 | try {
733 | await invalidService.listDAOs({
734 | limit: 2,
735 | sortBy: 'popular'
736 | });
737 | fail('Should have thrown an error');
738 | } catch (error) {
739 | expect(error).toBeDefined();
740 | expect(String(error)).toContain('Failed to fetch DAOs');
741 | expect(String(error)).toContain('502');
742 | }
743 | }, 60000);
744 |
745 | it('should handle rate limiting', async () => {
746 | const promises = Array(5).fill(null).map(() =>
747 | tallyService.listDAOs({
748 | limit: 1,
749 | sortBy: 'popular'
750 | })
751 | );
752 |
753 | try {
754 | await Promise.all(promises);
755 | // If we don't get rate limited, that's okay too
756 | } catch (error) {
757 | expect(error).toBeDefined();
758 | const errorString = String(error);
759 | // Check for either 429 (rate limit) or other API errors
760 | expect(
761 | errorString.includes('429') ||
762 | errorString.includes('Failed to fetch')
763 | ).toBe(true);
764 | }
765 | }, 60000);
766 | });
767 | });
768 |
769 | ================
770 | File: services/__tests__/tally.service.proposals.test.ts
771 | ================
772 | import { TallyService } from '../tally.service';
773 | import dotenv from 'dotenv';
774 |
775 | dotenv.config();
776 |
777 | const apiKey = process.env.TALLY_API_KEY;
778 | if (!apiKey) {
779 | throw new Error('TALLY_API_KEY environment variable is required');
780 | }
781 |
782 | // Helper function to add delay between API calls
783 | const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
784 |
785 | describe('TallyService - Proposals', () => {
786 | const service = new TallyService({ apiKey });
787 |
788 | // Test constants
789 | const UNISWAP_ORG_ID = '2206072050458560434';
790 | const UNISWAP_GOVERNOR_ID = 'eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3';
791 |
792 | // Add delay between each test
793 | beforeEach(async () => {
794 | await delay(1000); // 1 second delay between tests
795 | });
796 |
797 | describe('listProposals', () => {
798 | it('should list proposals with basic filters', async () => {
799 | const result = await service.listProposals({
800 | filters: {
801 | organizationId: UNISWAP_ORG_ID
802 | },
803 | page: {
804 | limit: 5
805 | }
806 | });
807 |
808 | // Check response structure
809 | expect(result).toHaveProperty('proposals');
810 | expect(result.proposals).toHaveProperty('nodes');
811 | expect(Array.isArray(result.proposals.nodes)).toBe(true);
812 |
813 | // If there are proposals, check their structure
814 | if (result.proposals.nodes.length > 0) {
815 | const proposal = result.proposals.nodes[0];
816 | expect(proposal).toHaveProperty('id');
817 | expect(proposal).toHaveProperty('onchainId');
818 | expect(proposal).toHaveProperty('status');
819 | expect(proposal).toHaveProperty('metadata');
820 | expect(proposal).toHaveProperty('voteStats');
821 | expect(proposal).toHaveProperty('governor');
822 |
823 | // Check metadata structure
824 | expect(proposal.metadata).toHaveProperty('title');
825 | expect(proposal.metadata).toHaveProperty('description');
826 |
827 | // Check governor structure
828 | expect(proposal.governor).toHaveProperty('id');
829 | expect(proposal.governor).toHaveProperty('name');
830 | expect(proposal.governor.organization).toHaveProperty('name');
831 | expect(proposal.governor.organization).toHaveProperty('slug');
832 | }
833 | });
834 |
835 | it('should handle pagination correctly', async () => {
836 | // First page with smaller limit
837 | const firstPage = await service.listProposals({
838 | filters: {
839 | organizationId: UNISWAP_ORG_ID
840 | },
841 | page: {
842 | limit: 2
843 | }
844 | });
845 |
846 | expect(firstPage.proposals.nodes.length).toBe(2);
847 | expect(firstPage.proposals.pageInfo).toHaveProperty('lastCursor');
848 | const firstPageIds = firstPage.proposals.nodes.map(p => p.id);
849 |
850 | await delay(1000);
851 |
852 | // Fetch second page
853 | const secondPage = await service.listProposals({
854 | filters: {
855 | organizationId: UNISWAP_ORG_ID
856 | },
857 | page: {
858 | limit: 2,
859 | afterCursor: firstPage.proposals.pageInfo.lastCursor
860 | }
861 | });
862 |
863 | expect(secondPage.proposals.nodes.length).toBe(2);
864 | const secondPageIds = secondPage.proposals.nodes.map(p => p.id);
865 |
866 | // Verify pages contain different proposals
867 | expect(firstPageIds).not.toEqual(secondPageIds);
868 | });
869 |
870 | it('should apply all filters correctly', async () => {
871 | const result = await service.listProposals({
872 | filters: {
873 | organizationId: UNISWAP_ORG_ID,
874 | governorId: UNISWAP_GOVERNOR_ID,
875 | includeArchived: true,
876 | isDraft: false
877 | },
878 | page: {
879 | limit: 3
880 | },
881 | sort: {
882 | isDescending: true,
883 | sortBy: "id"
884 | }
885 | });
886 |
887 | expect(result.proposals.nodes.length).toBeLessThanOrEqual(3);
888 | if (result.proposals.nodes.length > 1) {
889 | // Verify sorting
890 | const ids = result.proposals.nodes.map(p => BigInt(p.id));
891 | const isSorted = ids.every((id, i) => i === 0 || id <= ids[i - 1]);
892 | expect(isSorted).toBe(true);
893 | }
894 | });
895 | });
896 |
897 | describe('getProposal', () => {
898 | let proposalId: string;
899 |
900 | beforeAll(async () => {
901 | // Get a real proposal ID from the list
902 | const response = await service.listProposals({
903 | filters: {
904 | organizationId: UNISWAP_ORG_ID
905 | },
906 | page: {
907 | limit: 1
908 | }
909 | });
910 |
911 | if (response.proposals.nodes.length === 0) {
912 | throw new Error('No proposals found for testing');
913 | }
914 |
915 | proposalId = response.proposals.nodes[0].id;
916 | console.log('Using proposal ID:', proposalId);
917 | });
918 |
919 | it('should get proposal by ID', async () => {
920 | const result = await service.getProposal({
921 | id: proposalId
922 | });
923 |
924 | expect(result).toHaveProperty('proposal');
925 | const proposal = result.proposal;
926 |
927 | // Check basic properties
928 | expect(proposal).toHaveProperty('id');
929 | expect(proposal).toHaveProperty('onchainId');
930 | expect(proposal).toHaveProperty('status');
931 | expect(proposal).toHaveProperty('metadata');
932 | expect(proposal).toHaveProperty('voteStats');
933 | expect(proposal).toHaveProperty('governor');
934 |
935 | // Check metadata
936 | expect(proposal.metadata).toHaveProperty('title');
937 | expect(proposal.metadata).toHaveProperty('description');
938 | expect(proposal.metadata).toHaveProperty('discourseURL');
939 | expect(proposal.metadata).toHaveProperty('snapshotURL');
940 |
941 | // Check vote stats
942 | expect(Array.isArray(proposal.voteStats)).toBe(true);
943 | if (proposal.voteStats.length > 0) {
944 | expect(proposal.voteStats[0]).toHaveProperty('votesCount');
945 | expect(proposal.voteStats[0]).toHaveProperty('votersCount');
946 | expect(proposal.voteStats[0]).toHaveProperty('type');
947 | expect(proposal.voteStats[0]).toHaveProperty('percent');
948 | }
949 | });
950 |
951 | it('should get proposal by onchain ID', async () => {
952 | // First get a proposal with an onchain ID
953 | const listResponse = await service.listProposals({
954 | filters: {
955 | organizationId: UNISWAP_ORG_ID
956 | },
957 | page: {
958 | limit: 5
959 | }
960 | });
961 |
962 | const proposalWithOnchainId = listResponse.proposals.nodes.find(p => p.onchainId);
963 | if (!proposalWithOnchainId) {
964 | console.log('No proposal with onchain ID found, skipping test');
965 | return;
966 | }
967 |
968 | const result = await service.getProposal({
969 | onchainId: proposalWithOnchainId.onchainId,
970 | governorId: UNISWAP_GOVERNOR_ID
971 | });
972 |
973 | expect(result).toHaveProperty('proposal');
974 | expect(result.proposal.onchainId).toBe(proposalWithOnchainId.onchainId);
975 | });
976 |
977 | it('should include archived proposals', async () => {
978 | const result = await service.getProposal({
979 | id: proposalId,
980 | includeArchived: true
981 | });
982 |
983 | expect(result).toHaveProperty('proposal');
984 | expect(result.proposal.id).toBe(proposalId);
985 | });
986 |
987 | it('should handle errors for invalid proposal ID', async () => {
988 | await expect(service.getProposal({
989 | id: 'invalid-id'
990 | })).rejects.toThrow();
991 | });
992 |
993 | it('should handle errors when using onchainId without governorId', async () => {
994 | await expect(service.getProposal({
995 | onchainId: '1'
996 | })).rejects.toThrow();
997 | });
998 |
999 | it('should format proposal correctly', () => {
1000 | const mockProposal = {
1001 | id: '123',
1002 | onchainId: '1',
1003 | status: 'active' as const,
1004 | quorum: '1000000',
1005 | metadata: {
1006 | title: 'Test Proposal',
1007 | description: 'Test Description',
1008 | discourseURL: 'https://example.com',
1009 | snapshotURL: 'https://snapshot.org'
1010 | },
1011 | start: {
1012 | timestamp: '2023-01-01T00:00:00Z'
1013 | },
1014 | end: {
1015 | timestamp: '2023-01-08T00:00:00Z'
1016 | },
1017 | executableCalls: [{
1018 | value: '0',
1019 | target: '0x123',
1020 | calldata: '0x',
1021 | signature: 'test()',
1022 | type: 'call'
1023 | }],
1024 | voteStats: [{
1025 | votesCount: '1000000000000000000',
1026 | votersCount: 100,
1027 | type: 'for' as const,
1028 | percent: 75
1029 | }],
1030 | governor: {
1031 | id: 'gov-1',
1032 | chainId: 'eip155:1',
1033 | name: 'Test Governor',
1034 | token: {
1035 | decimals: 18
1036 | },
1037 | organization: {
1038 | name: 'Test Org',
1039 | slug: 'test'
1040 | }
1041 | },
1042 | proposer: {
1043 | address: '0x123',
1044 | name: 'Test Proposer',
1045 | picture: 'https://example.com/avatar.png'
1046 | }
1047 | };
1048 |
1049 | const formatted = TallyService.formatProposal(mockProposal);
1050 | expect(typeof formatted).toBe('string');
1051 | expect(formatted).toContain('Test Proposal');
1052 | expect(formatted).toContain('Test Description');
1053 | expect(formatted).toContain('Test Governor');
1054 | });
1055 | });
1056 | });
1057 |
1058 | ================
1059 | File: services/__tests__/tally.service.test.ts
1060 | ================
1061 | import { TallyService } from '../tally.service';
1062 | import dotenv from 'dotenv';
1063 |
1064 | dotenv.config();
1065 |
1066 | const apiKey = process.env.TALLY_API_KEY;
1067 | if (!apiKey) {
1068 | throw new Error('TALLY_API_KEY environment variable is required');
1069 | }
1070 |
1071 | describe('TallyService', () => {
1072 | let tallyService: TallyService;
1073 |
1074 | beforeAll(() => {
1075 | tallyService = new TallyService({ apiKey });
1076 | });
1077 |
1078 | describe('getDAO', () => {
1079 | it('should fetch Uniswap DAO details', async () => {
1080 | const dao = await tallyService.getDAO('uniswap');
1081 | expect(dao).toBeDefined();
1082 | expect(dao.name).toBe('Uniswap');
1083 | expect(dao.slug).toBe('uniswap');
1084 | expect(dao.chainIds).toContain('eip155:1');
1085 | expect(dao.governorIds).toBeDefined();
1086 | expect(dao.tokenIds).toBeDefined();
1087 | expect(dao.metadata).toBeDefined();
1088 | if (dao.metadata) {
1089 | expect(dao.metadata.icon).toBeDefined();
1090 | }
1091 | }, 30000);
1092 | });
1093 |
1094 | describe('listDelegates', () => {
1095 | it('should fetch delegates for Uniswap', async () => {
1096 | const result = await tallyService.listDelegates({
1097 | organizationSlug: 'uniswap',
1098 | limit: 20,
1099 | hasVotes: true
1100 | });
1101 |
1102 | // Check the structure of the response
1103 | expect(result).toHaveProperty('delegates');
1104 | expect(result).toHaveProperty('pageInfo');
1105 | expect(Array.isArray(result.delegates)).toBe(true);
1106 |
1107 | // Check that we got some delegates
1108 | expect(result.delegates.length).toBeGreaterThan(0);
1109 |
1110 | // Check the structure of a delegate
1111 | const firstDelegate = result.delegates[0];
1112 | expect(firstDelegate).toHaveProperty('id');
1113 | expect(firstDelegate).toHaveProperty('account');
1114 | expect(firstDelegate).toHaveProperty('votesCount');
1115 | expect(firstDelegate).toHaveProperty('delegatorsCount');
1116 |
1117 | // Check account properties
1118 | expect(firstDelegate.account).toHaveProperty('address');
1119 | expect(typeof firstDelegate.account.address).toBe('string');
1120 |
1121 | // Check that votesCount is a string (since it's a large number)
1122 | expect(typeof firstDelegate.votesCount).toBe('string');
1123 |
1124 | // Check that delegatorsCount is a number
1125 | expect(typeof firstDelegate.delegatorsCount).toBe('number');
1126 |
1127 | // Log the first delegate for manual inspection
1128 | }, 30000);
1129 |
1130 | it('should handle pagination correctly', async () => {
1131 | // First page
1132 | const firstPage = await tallyService.listDelegates({
1133 | organizationSlug: 'uniswap',
1134 | limit: 10
1135 | });
1136 |
1137 | expect(firstPage.delegates.length).toBeLessThanOrEqual(10);
1138 | expect(firstPage.pageInfo.lastCursor).toBeTruthy();
1139 |
1140 | // Second page using the cursor only if it's not null
1141 | if (firstPage.pageInfo.lastCursor) {
1142 | const secondPage = await tallyService.listDelegates({
1143 | organizationSlug: 'uniswap',
1144 | limit: 10,
1145 | afterCursor: firstPage.pageInfo.lastCursor
1146 | });
1147 |
1148 | expect(secondPage.delegates.length).toBeLessThanOrEqual(10);
1149 | expect(secondPage.delegates[0].id).not.toBe(firstPage.delegates[0].id);
1150 | }
1151 | }, 30000);
1152 | });
1153 | });
1154 |
1155 | ================
1156 | File: services/delegates/delegates.queries.ts
1157 | ================
1158 | import { gql } from 'graphql-request';
1159 |
1160 | export const LIST_DELEGATES_QUERY = gql`
1161 | query Delegates($input: DelegatesInput!) {
1162 | delegates(input: $input) {
1163 | nodes {
1164 | ... on Delegate {
1165 | id
1166 | account {
1167 | address
1168 | bio
1169 | name
1170 | picture
1171 | }
1172 | votesCount
1173 | delegatorsCount
1174 | statement {
1175 | statementSummary
1176 | }
1177 | }
1178 | }
1179 | pageInfo {
1180 | firstCursor
1181 | lastCursor
1182 | }
1183 | }
1184 | }
1185 | `;
1186 |
1187 | ================
1188 | File: services/delegates/delegates.types.ts
1189 | ================
1190 | import { PageInfo } from '../organizations/organizations.types.js';
1191 |
1192 | // Input Types
1193 | export interface ListDelegatesInput {
1194 | organizationId?: string;
1195 | organizationSlug?: string;
1196 | governorId?: string;
1197 | limit?: number;
1198 | afterCursor?: string;
1199 | beforeCursor?: string;
1200 | hasVotes?: boolean;
1201 | hasDelegators?: boolean;
1202 | isSeekingDelegation?: boolean;
1203 | sortBy?: 'id' | 'votes';
1204 | isDescending?: boolean;
1205 | }
1206 |
1207 | // Response Types
1208 | export interface Delegate {
1209 | id: string;
1210 | account: {
1211 | address: string;
1212 | bio?: string;
1213 | name?: string;
1214 | picture?: string | null;
1215 | };
1216 | votesCount: string;
1217 | delegatorsCount: number;
1218 | statement?: {
1219 | statementSummary?: string;
1220 | };
1221 | }
1222 |
1223 | export interface DelegatesResponse {
1224 | delegates: {
1225 | nodes: Delegate[];
1226 | pageInfo: PageInfo;
1227 | };
1228 | }
1229 |
1230 | export interface ListDelegatesResponse {
1231 | data: DelegatesResponse;
1232 | errors?: Array<{
1233 | message: string;
1234 | path: string[];
1235 | extensions: {
1236 | code: number;
1237 | status: {
1238 | code: number;
1239 | message: string;
1240 | };
1241 | };
1242 | }>;
1243 | }
1244 |
1245 | ================
1246 | File: services/delegates/index.ts
1247 | ================
1248 | export * from './delegates.types.js';
1249 | export * from './delegates.queries.js';
1250 | export * from './listDelegates.js';
1251 |
1252 | ================
1253 | File: services/delegates/listDelegates.ts
1254 | ================
1255 | import { GraphQLClient } from 'graphql-request';
1256 | import { LIST_DELEGATES_QUERY } from './delegates.queries.js';
1257 | import { DelegatesResponse, Delegate } from './delegates.types.js';
1258 | import { PageInfo } from '../organizations/organizations.types.js';
1259 | import { getDAO } from '../organizations/getDAO.js';
1260 |
1261 | export async function listDelegates(
1262 | client: GraphQLClient,
1263 | input: {
1264 | organizationId?: string;
1265 | organizationSlug?: string;
1266 | limit?: number;
1267 | afterCursor?: string;
1268 | beforeCursor?: string;
1269 | hasVotes?: boolean;
1270 | hasDelegators?: boolean;
1271 | isSeekingDelegation?: boolean;
1272 | }
1273 | ): Promise<{
1274 | delegates: Delegate[];
1275 | pageInfo: PageInfo;
1276 | }> {
1277 | let organizationId = input.organizationId;
1278 |
1279 | // If organizationId is not provided but slug is, get the DAO first
1280 | if (!organizationId && input.organizationSlug) {
1281 | const dao = await getDAO(client, input.organizationSlug);
1282 | organizationId = dao.id;
1283 | }
1284 |
1285 | if (!organizationId) {
1286 | throw new Error('Either organizationId or organizationSlug must be provided');
1287 | }
1288 |
1289 | try {
1290 | const response = await client.request<DelegatesResponse>(LIST_DELEGATES_QUERY, {
1291 | input: {
1292 | filters: {
1293 | organizationId,
1294 | hasVotes: input.hasVotes,
1295 | hasDelegators: input.hasDelegators,
1296 | isSeekingDelegation: input.isSeekingDelegation,
1297 | },
1298 | sort: {
1299 | isDescending: true,
1300 | sortBy: 'votes',
1301 | },
1302 | page: {
1303 | limit: Math.min(input.limit || 20, 50),
1304 | afterCursor: input.afterCursor,
1305 | beforeCursor: input.beforeCursor,
1306 | },
1307 | },
1308 | });
1309 |
1310 | return {
1311 | delegates: response.delegates.nodes,
1312 | pageInfo: response.delegates.pageInfo,
1313 | };
1314 | } catch (error) {
1315 | throw new Error(`Failed to fetch delegates: ${error instanceof Error ? error.message : 'Unknown error'}`);
1316 | }
1317 | }
1318 |
1319 | ================
1320 | File: services/delegators/delegators.queries.ts
1321 | ================
1322 | import { gql } from 'graphql-request';
1323 |
1324 | export const GET_DELEGATORS_QUERY = gql`
1325 | query GetDelegators($input: DelegationsInput!) {
1326 | delegators(input: $input) {
1327 | nodes {
1328 | ... on Delegation {
1329 | chainId
1330 | delegator {
1331 | address
1332 | name
1333 | picture
1334 | twitter
1335 | ens
1336 | }
1337 | blockNumber
1338 | blockTimestamp
1339 | votes
1340 | token {
1341 | id
1342 | name
1343 | symbol
1344 | decimals
1345 | }
1346 | }
1347 | }
1348 | pageInfo {
1349 | firstCursor
1350 | lastCursor
1351 | }
1352 | }
1353 | }
1354 | `;
1355 |
1356 | ================
1357 | File: services/delegators/delegators.types.ts
1358 | ================
1359 | import { PageInfo } from "../organizations/organizations.types.js";
1360 |
1361 | // Input Types
1362 | export interface GetDelegatorsParams {
1363 | address: string;
1364 | organizationId?: string;
1365 | organizationSlug?: string;
1366 | governorId?: string;
1367 | limit?: number;
1368 | afterCursor?: string;
1369 | beforeCursor?: string;
1370 | sortBy?: "id" | "votes";
1371 | isDescending?: boolean;
1372 | }
1373 |
1374 | // Response Types
1375 | export interface TokenInfo {
1376 | id: string;
1377 | name: string;
1378 | symbol: string;
1379 | decimals: number;
1380 | }
1381 |
1382 | export interface Delegation {
1383 | chainId: string;
1384 | blockNumber: number;
1385 | blockTimestamp: string;
1386 | votes: string;
1387 | delegator: {
1388 | address: string;
1389 | name?: string;
1390 | picture?: string;
1391 | twitter?: string;
1392 | ens?: string;
1393 | };
1394 | token?: {
1395 | id: string;
1396 | name: string;
1397 | symbol: string;
1398 | decimals: number;
1399 | };
1400 | }
1401 |
1402 | export interface DelegationsResponse {
1403 | delegators: {
1404 | nodes: Delegation[];
1405 | pageInfo: PageInfo;
1406 | };
1407 | }
1408 |
1409 | export interface GetDelegatorsResponse {
1410 | data: DelegationsResponse;
1411 | errors?: Array<{
1412 | message: string;
1413 | path: string[];
1414 | extensions: {
1415 | code: number;
1416 | status: {
1417 | code: number;
1418 | message: string;
1419 | };
1420 | };
1421 | }>;
1422 | }
1423 |
1424 | ================
1425 | File: services/delegators/getDelegators.ts
1426 | ================
1427 | import { GraphQLClient } from 'graphql-request';
1428 | import { GET_DELEGATORS_QUERY } from './delegators.queries.js';
1429 | import { GetDelegatorsParams, DelegationsResponse, Delegation } from './delegators.types.js';
1430 | import { PageInfo } from '../organizations/organizations.types.js';
1431 | import { getDAO } from '../organizations/getDAO.js';
1432 |
1433 | export async function getDelegators(
1434 | client: GraphQLClient,
1435 | params: GetDelegatorsParams
1436 | ): Promise<{
1437 | delegators: Delegation[];
1438 | pageInfo: PageInfo;
1439 | }> {
1440 | try {
1441 | let organizationId = params.organizationId;
1442 |
1443 | // If organizationId is not provided but slug is, get the organization ID
1444 | if (!organizationId && params.organizationSlug) {
1445 | const dao = await getDAO(client, params.organizationSlug);
1446 | organizationId = dao.id;
1447 | }
1448 |
1449 | if (!organizationId && !params.governorId) {
1450 | throw new Error('Either organizationId/organizationSlug or governorId must be provided');
1451 | }
1452 |
1453 | const input = {
1454 | filters: {
1455 | address: params.address,
1456 | ...(organizationId && { organizationId }),
1457 | ...(params.governorId && { governorId: params.governorId })
1458 | },
1459 | page: {
1460 | limit: Math.min(params.limit || 20, 50),
1461 | ...(params.afterCursor && { afterCursor: params.afterCursor }),
1462 | ...(params.beforeCursor && { beforeCursor: params.beforeCursor })
1463 | },
1464 | ...(params.sortBy && {
1465 | sort: {
1466 | sortBy: params.sortBy,
1467 | isDescending: params.isDescending ?? true
1468 | }
1469 | })
1470 | };
1471 |
1472 | const response = await client.request<DelegationsResponse>(
1473 | GET_DELEGATORS_QUERY,
1474 | { input }
1475 | );
1476 |
1477 | return {
1478 | delegators: response.delegators.nodes,
1479 | pageInfo: response.delegators.pageInfo
1480 | };
1481 | } catch (error) {
1482 | throw new Error(`Failed to fetch delegators: ${error instanceof Error ? error.message : 'Unknown error'}`);
1483 | }
1484 | }
1485 |
1486 | ================
1487 | File: services/delegators/index.ts
1488 | ================
1489 | export * from './delegators.types.js';
1490 | export * from './delegators.queries.js';
1491 | export * from './getDelegators.js';
1492 |
1493 | ================
1494 | File: services/organizations/getDAO.ts
1495 | ================
1496 | import { GraphQLClient } from 'graphql-request';
1497 | import { GET_DAO_QUERY } from './organizations.queries.js';
1498 | import { Organization } from './organizations.types.js';
1499 |
1500 | export async function getDAO(
1501 | client: GraphQLClient,
1502 | slug: string
1503 | ): Promise<Organization> {
1504 | try {
1505 | const input = { slug };
1506 | const response = await client.request<{ organization: Organization }>(GET_DAO_QUERY, { input });
1507 |
1508 | if (!response.organization) {
1509 | throw new Error(`DAO not found: ${slug}`);
1510 | }
1511 |
1512 | // Map the response to match our Organization interface
1513 | const dao: Organization = {
1514 | ...response.organization,
1515 | metadata: {
1516 | ...response.organization.metadata,
1517 | websiteUrl: response.organization.metadata?.socials?.website || undefined,
1518 | discord: response.organization.metadata?.socials?.discord || undefined,
1519 | twitter: response.organization.metadata?.socials?.twitter || undefined,
1520 | }
1521 | };
1522 |
1523 | return dao;
1524 | } catch (error) {
1525 | throw new Error(`Failed to fetch DAO: ${error instanceof Error ? error.message : 'Unknown error'}`);
1526 | }
1527 | }
1528 |
1529 | ================
1530 | File: services/organizations/index.ts
1531 | ================
1532 | export * from './organizations.types.js';
1533 | export * from './organizations.queries.js';
1534 | export * from './listDAOs.js';
1535 | export * from './getDAO.js';
1536 |
1537 | ================
1538 | File: services/organizations/listDAOs.ts
1539 | ================
1540 | import { GraphQLClient } from 'graphql-request';
1541 | import { LIST_DAOS_QUERY } from './organizations.queries.js';
1542 | import { ListDAOsParams, OrganizationsInput, OrganizationsResponse } from './organizations.types.js';
1543 |
1544 | export async function listDAOs(
1545 | client: GraphQLClient,
1546 | params: ListDAOsParams = {}
1547 | ): Promise<OrganizationsResponse> {
1548 | const input: OrganizationsInput = {
1549 | sort: {
1550 | sortBy: params.sortBy || "popular",
1551 | isDescending: true
1552 | },
1553 | page: {
1554 | limit: Math.min(params.limit || 20, 50)
1555 | }
1556 | };
1557 |
1558 | if (params.afterCursor) {
1559 | input.page!.afterCursor = params.afterCursor;
1560 | }
1561 |
1562 | if (params.beforeCursor) {
1563 | input.page!.beforeCursor = params.beforeCursor;
1564 | }
1565 |
1566 | try {
1567 | const response = await client.request<OrganizationsResponse>(LIST_DAOS_QUERY, { input });
1568 | return response;
1569 | } catch (error) {
1570 | throw new Error(`Failed to fetch DAOs: ${error instanceof Error ? error.message : 'Unknown error'}`);
1571 | }
1572 | }
1573 |
1574 | ================
1575 | File: services/organizations/organizations.queries.ts
1576 | ================
1577 | import { gql } from 'graphql-request';
1578 |
1579 | export const LIST_DAOS_QUERY = gql`
1580 | query Organizations($input: OrganizationsInput!) {
1581 | organizations(input: $input) {
1582 | nodes {
1583 | ... on Organization {
1584 | id
1585 | name
1586 | slug
1587 | chainIds
1588 | proposalsCount
1589 | hasActiveProposals
1590 | tokenOwnersCount
1591 | delegatesCount
1592 | }
1593 | }
1594 | pageInfo {
1595 | firstCursor
1596 | lastCursor
1597 | }
1598 | }
1599 | }
1600 | `;
1601 |
1602 | export const GET_DAO_QUERY = gql`
1603 | query OrganizationBySlug($input: OrganizationInput!) {
1604 | organization(input: $input) {
1605 | id
1606 | name
1607 | slug
1608 | chainIds
1609 | governorIds
1610 | tokenIds
1611 | hasActiveProposals
1612 | proposalsCount
1613 | delegatesCount
1614 | tokenOwnersCount
1615 | metadata {
1616 | description
1617 | icon
1618 | socials {
1619 | website
1620 | discord
1621 | telegram
1622 | twitter
1623 | discourse
1624 | others {
1625 | label
1626 | value
1627 | }
1628 | }
1629 | karmaName
1630 | }
1631 | features {
1632 | name
1633 | enabled
1634 | }
1635 | }
1636 | }
1637 | `;
1638 |
1639 | ================
1640 | File: services/organizations/organizations.types.ts
1641 | ================
1642 | // Basic Types
1643 | export type OrganizationsSortBy = "id" | "name" | "explore" | "popular";
1644 |
1645 | // Input Types
1646 | export interface OrganizationsSortInput {
1647 | isDescending: boolean;
1648 | sortBy: OrganizationsSortBy;
1649 | }
1650 |
1651 | export interface PageInput {
1652 | afterCursor?: string;
1653 | beforeCursor?: string;
1654 | limit?: number;
1655 | }
1656 |
1657 | export interface OrganizationsFiltersInput {
1658 | hasLogo?: boolean;
1659 | chainId?: string;
1660 | isMember?: boolean;
1661 | address?: string;
1662 | slug?: string;
1663 | name?: string;
1664 | }
1665 |
1666 | export interface OrganizationsInput {
1667 | filters?: OrganizationsFiltersInput;
1668 | page?: PageInput;
1669 | sort?: OrganizationsSortInput;
1670 | search?: string;
1671 | }
1672 |
1673 | export interface ListDAOsParams {
1674 | limit?: number;
1675 | afterCursor?: string;
1676 | beforeCursor?: string;
1677 | sortBy?: OrganizationsSortBy;
1678 | }
1679 |
1680 | // Response Types
1681 | export interface Organization {
1682 | id: string;
1683 | slug: string;
1684 | name: string;
1685 | chainIds: string[];
1686 | tokenIds?: string[];
1687 | governorIds?: string[];
1688 | metadata?: {
1689 | description?: string;
1690 | icon?: string;
1691 | websiteUrl?: string;
1692 | twitter?: string;
1693 | discord?: string;
1694 | github?: string;
1695 | termsOfService?: string;
1696 | governanceUrl?: string;
1697 | socials?: {
1698 | website?: string;
1699 | discord?: string;
1700 | telegram?: string;
1701 | twitter?: string;
1702 | discourse?: string;
1703 | others?: Array<{
1704 | label: string;
1705 | value: string;
1706 | }>;
1707 | };
1708 | karmaName?: string;
1709 | };
1710 | features?: Array<{
1711 | name: string;
1712 | enabled: boolean;
1713 | }>;
1714 | hasActiveProposals: boolean;
1715 | proposalsCount: number;
1716 | delegatesCount: number;
1717 | tokenOwnersCount: number;
1718 | stats?: {
1719 | proposalsCount: number;
1720 | activeProposalsCount: number;
1721 | tokenHoldersCount: number;
1722 | votersCount: number;
1723 | delegatesCount: number;
1724 | delegatedVotesCount: string;
1725 | };
1726 | }
1727 |
1728 | export interface PageInfo {
1729 | firstCursor: string | null;
1730 | lastCursor: string | null;
1731 | }
1732 |
1733 | export interface OrganizationsResponse {
1734 | organizations: {
1735 | nodes: Organization[];
1736 | pageInfo: PageInfo;
1737 | };
1738 | }
1739 |
1740 | export interface GetDAOResponse {
1741 | organizations: {
1742 | nodes: Organization[];
1743 | };
1744 | }
1745 |
1746 | export interface ListDAOsResponse {
1747 | data: OrganizationsResponse;
1748 | errors?: Array<{
1749 | message: string;
1750 | path: string[];
1751 | extensions: {
1752 | code: number;
1753 | status: {
1754 | code: number;
1755 | message: string;
1756 | };
1757 | };
1758 | }>;
1759 | }
1760 |
1761 | export interface GetDAOBySlugResponse {
1762 | data: GetDAOResponse;
1763 | errors?: Array<{
1764 | message: string;
1765 | path: string[];
1766 | extensions: {
1767 | code: number;
1768 | status: {
1769 | code: number;
1770 | message: string;
1771 | };
1772 | };
1773 | }>;
1774 | }
1775 |
1776 | ================
1777 | File: services/proposals/getProposal.ts
1778 | ================
1779 | import { GraphQLClient } from 'graphql-request';
1780 | import { GET_PROPOSAL_QUERY } from './proposals.queries.js';
1781 | import type { ProposalInput, ProposalDetailsResponse } from './getProposal.types.js';
1782 | import { getDAO } from '../organizations/getDAO.js';
1783 |
1784 | export async function getProposal(
1785 | client: GraphQLClient,
1786 | input: ProposalInput & { organizationSlug?: string }
1787 | ): Promise<ProposalDetailsResponse> {
1788 | try {
1789 | let apiInput: ProposalInput = { ...input };
1790 | delete (apiInput as any).organizationSlug; // Remove organizationSlug before API call
1791 |
1792 | // If organizationSlug is provided but no organizationId, get the DAO first
1793 | if (input.organizationSlug && !apiInput.governorId) {
1794 | const dao = await getDAO(client, input.organizationSlug);
1795 | // Use the first governor ID from the DAO
1796 | if (dao.governorIds && dao.governorIds.length > 0) {
1797 | apiInput.governorId = dao.governorIds[0];
1798 | }
1799 | }
1800 |
1801 | // Ensure ID is not wrapped in quotes if it's numeric
1802 | if (apiInput.id && typeof apiInput.id === 'string' && /^\d+$/.test(apiInput.id)) {
1803 | apiInput = {
1804 | ...apiInput,
1805 | id: apiInput.id.replace(/['"]/g, '') // Remove any quotes
1806 | };
1807 | }
1808 |
1809 | const response = await client.request<ProposalDetailsResponse>(GET_PROPOSAL_QUERY, { input: apiInput });
1810 | return response;
1811 | } catch (error) {
1812 | throw new Error(`Failed to fetch proposal: ${error instanceof Error ? error.message : 'Unknown error'}`);
1813 | }
1814 | }
1815 |
1816 | ================
1817 | File: services/proposals/getProposal.types.ts
1818 | ================
1819 | import { AccountID, IntID } from './listProposals.types.js';
1820 |
1821 | // Input Types
1822 | export interface ProposalInput {
1823 | id?: IntID;
1824 | onchainId?: string;
1825 | governorId?: AccountID;
1826 | includeArchived?: boolean;
1827 | isLatest?: boolean;
1828 | }
1829 |
1830 | export interface GetProposalVariables {
1831 | input: ProposalInput;
1832 | }
1833 |
1834 | // Response Types
1835 | export interface ProposalDetailsMetadata {
1836 | title: string;
1837 | description: string;
1838 | discourseURL: string;
1839 | snapshotURL: string;
1840 | }
1841 |
1842 | export interface ProposalDetailsVoteStats {
1843 | votesCount: string;
1844 | votersCount: number;
1845 | type: "for" | "against" | "abstain" | "pendingfor" | "pendingagainst" | "pendingabstain";
1846 | percent: number;
1847 | }
1848 |
1849 | export interface ProposalDetailsGovernor {
1850 | id: AccountID;
1851 | chainId: string;
1852 | name: string;
1853 | token: {
1854 | decimals: number;
1855 | };
1856 | organization: {
1857 | name: string;
1858 | slug: string;
1859 | };
1860 | }
1861 |
1862 | export interface ProposalDetailsProposer {
1863 | address: AccountID;
1864 | name: string;
1865 | picture?: string;
1866 | }
1867 |
1868 | export interface TimeBlock {
1869 | timestamp: string;
1870 | }
1871 |
1872 | export interface ExecutableCall {
1873 | value: string;
1874 | target: string;
1875 | calldata: string;
1876 | signature: string;
1877 | type: string;
1878 | }
1879 |
1880 | export interface ProposalDetails {
1881 | id: IntID;
1882 | onchainId: string;
1883 | metadata: ProposalDetailsMetadata;
1884 | status: "active" | "canceled" | "defeated" | "executed" | "expired" | "pending" | "queued" | "succeeded";
1885 | quorum: string;
1886 | start: TimeBlock;
1887 | end: TimeBlock;
1888 | executableCalls: ExecutableCall[];
1889 | voteStats: ProposalDetailsVoteStats[];
1890 | governor: ProposalDetailsGovernor;
1891 | proposer: ProposalDetailsProposer;
1892 | }
1893 |
1894 | export interface ProposalDetailsResponse {
1895 | proposal: ProposalDetails;
1896 | }
1897 |
1898 | export interface GetProposalResponse {
1899 | data: ProposalDetailsResponse;
1900 | errors?: Array<{
1901 | message: string;
1902 | path: string[];
1903 | extensions: {
1904 | code: number;
1905 | status: {
1906 | code: number;
1907 | message: string;
1908 | };
1909 | };
1910 | }>;
1911 | }
1912 |
1913 | ================
1914 | File: services/proposals/index.ts
1915 | ================
1916 | export * from './listProposals.types.js';
1917 | export * from './getProposal.types.js';
1918 | export * from './proposals.queries.js';
1919 | export * from './listProposals.js';
1920 | export * from './getProposal.js';
1921 |
1922 | ================
1923 | File: services/proposals/listProposals.ts
1924 | ================
1925 | import { GraphQLClient } from 'graphql-request';
1926 | import { LIST_PROPOSALS_QUERY } from './proposals.queries.js';
1927 | import { getDAO } from '../organizations/getDAO.js';
1928 | import type { ProposalsInput, ProposalsResponse } from './listProposals.types.js';
1929 |
1930 | export async function listProposals(
1931 | client: GraphQLClient,
1932 | input: ProposalsInput & { organizationSlug?: string }
1933 | ): Promise<ProposalsResponse> {
1934 | try {
1935 | let apiInput: ProposalsInput = { ...input };
1936 | delete (apiInput as any).organizationSlug; // Remove organizationSlug before API call
1937 |
1938 | // If organizationSlug is provided but no organizationId, get the DAO first
1939 | if (!apiInput.filters?.organizationId && input.organizationSlug) {
1940 | const dao = await getDAO(client, input.organizationSlug);
1941 | apiInput = {
1942 | ...apiInput,
1943 | filters: {
1944 | ...apiInput.filters,
1945 | organizationId: dao.id
1946 | }
1947 | };
1948 | }
1949 |
1950 | const response = await client.request<ProposalsResponse>(LIST_PROPOSALS_QUERY, { input: apiInput });
1951 | return response;
1952 | } catch (error) {
1953 | throw new Error(`Failed to fetch proposals: ${error instanceof Error ? error.message : 'Unknown error'}`);
1954 | }
1955 | }
1956 |
1957 | ================
1958 | File: services/proposals/listProposals.types.ts
1959 | ================
1960 | // Basic Types
1961 | export type AccountID = string;
1962 | export type IntID = string;
1963 |
1964 | // Input Types
1965 | export interface ProposalsInput {
1966 | filters?: {
1967 | governorId?: AccountID;
1968 | organizationId?: IntID;
1969 | includeArchived?: boolean;
1970 | isDraft?: boolean;
1971 | };
1972 | page?: {
1973 | afterCursor?: string;
1974 | beforeCursor?: string;
1975 | limit?: number; // max 50
1976 | };
1977 | sort?: {
1978 | isDescending: boolean;
1979 | sortBy: "id"; // default sorts by date
1980 | };
1981 | }
1982 |
1983 | export interface ListProposalsVariables {
1984 | input: ProposalsInput;
1985 | }
1986 |
1987 | // Response Types
1988 | export interface ProposalVoteStats {
1989 | votesCount: string;
1990 | percent: number;
1991 | type: "for" | "against" | "abstain" | "pendingfor" | "pendingagainst" | "pendingabstain";
1992 | votersCount: number;
1993 | }
1994 |
1995 | export interface ProposalMetadata {
1996 | description: string;
1997 | title: string;
1998 | discourseURL: string;
1999 | snapshotURL: string;
2000 | }
2001 |
2002 | export interface TimeBlock {
2003 | timestamp: string;
2004 | }
2005 |
2006 | export interface ExecutableCall {
2007 | value: string;
2008 | target: string;
2009 | calldata: string;
2010 | signature: string;
2011 | type: string;
2012 | }
2013 |
2014 | export interface ProposalGovernor {
2015 | id: AccountID;
2016 | chainId: string;
2017 | name: string;
2018 | token: {
2019 | decimals: number;
2020 | };
2021 | organization: {
2022 | name: string;
2023 | slug: string;
2024 | };
2025 | }
2026 |
2027 | export interface ProposalProposer {
2028 | address: AccountID;
2029 | name: string;
2030 | picture?: string;
2031 | }
2032 |
2033 | export interface Proposal {
2034 | id: IntID;
2035 | onchainId: string;
2036 | status: "active" | "canceled" | "defeated" | "executed" | "expired" | "pending" | "queued" | "succeeded";
2037 | createdAt: string;
2038 | quorum: string;
2039 | metadata: ProposalMetadata;
2040 | start: TimeBlock;
2041 | end: TimeBlock;
2042 | executableCalls: ExecutableCall[];
2043 | voteStats: ProposalVoteStats[];
2044 | governor: ProposalGovernor;
2045 | proposer: ProposalProposer;
2046 | }
2047 |
2048 | export interface ProposalsResponse {
2049 | proposals: {
2050 | nodes: Proposal[];
2051 | pageInfo: {
2052 | firstCursor: string;
2053 | lastCursor: string;
2054 | };
2055 | };
2056 | }
2057 |
2058 | export interface ListProposalsResponse {
2059 | data: ProposalsResponse;
2060 | errors?: Array<{
2061 | message: string;
2062 | path: string[];
2063 | extensions: {
2064 | code: number;
2065 | status: {
2066 | code: number;
2067 | message: string;
2068 | };
2069 | };
2070 | }>;
2071 | }
2072 |
2073 | ================
2074 | File: services/proposals/proposals.queries.ts
2075 | ================
2076 | import { gql } from 'graphql-request';
2077 |
2078 | export const LIST_PROPOSALS_QUERY = gql`
2079 | query GovernanceProposals($input: ProposalsInput!) {
2080 | proposals(input: $input) {
2081 | nodes {
2082 | ... on Proposal {
2083 | id
2084 | onchainId
2085 | status
2086 | createdAt
2087 | quorum
2088 | metadata {
2089 | description
2090 | title
2091 | discourseURL
2092 | snapshotURL
2093 | }
2094 | start {
2095 | ... on Block {
2096 | timestamp
2097 | }
2098 | ... on BlocklessTimestamp {
2099 | timestamp
2100 | }
2101 | }
2102 | end {
2103 | ... on Block {
2104 | timestamp
2105 | }
2106 | ... on BlocklessTimestamp {
2107 | timestamp
2108 | }
2109 | }
2110 | executableCalls {
2111 | value
2112 | target
2113 | calldata
2114 | signature
2115 | type
2116 | }
2117 | voteStats {
2118 | votesCount
2119 | percent
2120 | type
2121 | votersCount
2122 | }
2123 | governor {
2124 | id
2125 | chainId
2126 | name
2127 | token {
2128 | decimals
2129 | }
2130 | organization {
2131 | name
2132 | slug
2133 | }
2134 | }
2135 | proposer {
2136 | address
2137 | name
2138 | picture
2139 | }
2140 | }
2141 | }
2142 | pageInfo {
2143 | firstCursor
2144 | lastCursor
2145 | }
2146 | }
2147 | }
2148 | `;
2149 |
2150 | export const GET_PROPOSAL_QUERY = gql`
2151 | query ProposalDetails($input: ProposalInput!) {
2152 | proposal(input: $input) {
2153 | id
2154 | onchainId
2155 | metadata {
2156 | title
2157 | description
2158 | discourseURL
2159 | snapshotURL
2160 | }
2161 | status
2162 | quorum
2163 | start {
2164 | ... on Block {
2165 | timestamp
2166 | }
2167 | ... on BlocklessTimestamp {
2168 | timestamp
2169 | }
2170 | }
2171 | end {
2172 | ... on Block {
2173 | timestamp
2174 | }
2175 | ... on BlocklessTimestamp {
2176 | timestamp
2177 | }
2178 | }
2179 | executableCalls {
2180 | value
2181 | target
2182 | calldata
2183 | signature
2184 | type
2185 | }
2186 | voteStats {
2187 | votesCount
2188 | votersCount
2189 | type
2190 | percent
2191 | }
2192 | governor {
2193 | id
2194 | chainId
2195 | name
2196 | token {
2197 | decimals
2198 | }
2199 | organization {
2200 | name
2201 | slug
2202 | }
2203 | }
2204 | proposer {
2205 | address
2206 | name
2207 | picture
2208 | }
2209 | }
2210 | }
2211 | `;
2212 |
2213 | ================
2214 | File: services/index.ts
2215 | ================
2216 | export * from './organizations/index.js';
2217 | export * from './delegates/index.js';
2218 | export * from './delegators/index.js';
2219 | export * from './proposals/index.js';
2220 |
2221 | export interface TallyServiceConfig {
2222 | apiKey: string;
2223 | baseUrl?: string;
2224 | }
2225 |
2226 | ================
2227 | File: services/tally.service.ts
2228 | ================
2229 | import { GraphQLClient } from 'graphql-request';
2230 | import { listDAOs } from './organizations/listDAOs.js';
2231 | import { getDAO } from './organizations/getDAO.js';
2232 | import { listDelegates } from './delegates/listDelegates.js';
2233 | import { getDelegators } from './delegators/getDelegators.js';
2234 | import { listProposals } from './proposals/listProposals.js';
2235 | import { getProposal } from './proposals/getProposal.js';
2236 | import type {
2237 | Organization,
2238 | OrganizationsResponse,
2239 | ListDAOsParams,
2240 | } from './organizations/organizations.types.js';
2241 | import type { Delegate } from './delegates/delegates.types.js';
2242 | import type { Delegation, GetDelegatorsParams, TokenInfo } from './delegators/delegators.types.js';
2243 | import type { PageInfo } from './organizations/organizations.types.js';
2244 | import type {
2245 | ProposalsInput,
2246 | ProposalsResponse,
2247 | ProposalInput,
2248 | ProposalDetailsResponse,
2249 | } from './proposals/index.js';
2250 |
2251 | export interface TallyServiceConfig {
2252 | apiKey: string;
2253 | baseUrl?: string;
2254 | }
2255 |
2256 | export interface OpenAIFunctionDefinition {
2257 | name: string;
2258 | description: string;
2259 | parameters: {
2260 | type: string;
2261 | properties?: Record<string, unknown>;
2262 | required?: string[];
2263 | oneOf?: Array<{
2264 | required: string[];
2265 | properties: Record<string, unknown>;
2266 | }>;
2267 | };
2268 | }
2269 |
2270 | export const OPENAI_FUNCTION_DEFINITIONS: OpenAIFunctionDefinition[] = [
2271 | {
2272 | name: "list-daos",
2273 | description: "List DAOs on Tally sorted by specified criteria",
2274 | parameters: {
2275 | type: "object",
2276 | properties: {
2277 | limit: {
2278 | type: "number",
2279 | description: "Maximum number of DAOs to return (default: 20, max: 50)",
2280 | },
2281 | afterCursor: {
2282 | type: "string",
2283 | description: "Cursor for pagination",
2284 | },
2285 | sortBy: {
2286 | type: "string",
2287 | enum: ["id", "name", "explore", "popular"],
2288 | description: "How to sort the DAOs (default: popular). 'explore' prioritizes DAOs with live proposals",
2289 | },
2290 | },
2291 | },
2292 | },
2293 | {
2294 | name: "get-dao",
2295 | description: "Get detailed information about a specific DAO",
2296 | parameters: {
2297 | type: "object",
2298 | required: ["slug"],
2299 | properties: {
2300 | slug: {
2301 | type: "string",
2302 | description: "The DAO's slug (e.g., 'uniswap' or 'aave')",
2303 | },
2304 | },
2305 | },
2306 | },
2307 | {
2308 | name: "list-delegates",
2309 | description: "List delegates for a specific organization with their metadata",
2310 | parameters: {
2311 | type: "object",
2312 | required: ["organizationIdOrSlug"],
2313 | properties: {
2314 | organizationIdOrSlug: {
2315 | type: "string",
2316 | description: "The organization's ID or slug (e.g., 'arbitrum' or 'eip155:1:123')",
2317 | },
2318 | limit: {
2319 | type: "number",
2320 | description: "Maximum number of delegates to return (default: 20, max: 50)",
2321 | },
2322 | afterCursor: {
2323 | type: "string",
2324 | description: "Cursor for pagination",
2325 | },
2326 | hasVotes: {
2327 | type: "boolean",
2328 | description: "Filter for delegates with votes",
2329 | },
2330 | hasDelegators: {
2331 | type: "boolean",
2332 | description: "Filter for delegates with delegators",
2333 | },
2334 | isSeekingDelegation: {
2335 | type: "boolean",
2336 | description: "Filter for delegates seeking delegation",
2337 | },
2338 | },
2339 | },
2340 | },
2341 | {
2342 | name: "get-delegators",
2343 | description: "Get list of delegators for a specific address",
2344 | parameters: {
2345 | type: "object",
2346 | required: ["address"],
2347 | properties: {
2348 | address: {
2349 | type: "string",
2350 | description: "The Ethereum address to get delegators for (0x format)",
2351 | },
2352 | organizationId: {
2353 | type: "string",
2354 | description: "Filter by specific organization ID",
2355 | },
2356 | governorId: {
2357 | type: "string",
2358 | description: "Filter by specific governor ID",
2359 | },
2360 | limit: {
2361 | type: "number",
2362 | description: "Maximum number of delegators to return (default: 20, max: 50)",
2363 | },
2364 | afterCursor: {
2365 | type: "string",
2366 | description: "Cursor for pagination",
2367 | },
2368 | beforeCursor: {
2369 | type: "string",
2370 | description: "Cursor for previous page pagination",
2371 | },
2372 | sortBy: {
2373 | type: "string",
2374 | enum: ["id", "votes"],
2375 | description: "How to sort the delegators (default: id)",
2376 | },
2377 | isDescending: {
2378 | type: "boolean",
2379 | description: "Sort in descending order (default: true)",
2380 | },
2381 | },
2382 | },
2383 | },
2384 | {
2385 | name: "list-proposals",
2386 | description: "List proposals for a specific organization or governor",
2387 | parameters: {
2388 | type: "object",
2389 | properties: {
2390 | organizationId: {
2391 | type: "string",
2392 | description: "Filter by organization ID (large integer as string)",
2393 | },
2394 | organizationSlug: {
2395 | type: "string",
2396 | description: "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId",
2397 | },
2398 | governorId: {
2399 | type: "string",
2400 | description: "Filter by governor ID",
2401 | },
2402 | includeArchived: {
2403 | type: "boolean",
2404 | description: "Include archived proposals",
2405 | },
2406 | isDraft: {
2407 | type: "boolean",
2408 | description: "Filter for draft proposals",
2409 | },
2410 | limit: {
2411 | type: "number",
2412 | description: "Maximum number of proposals to return (default: 20, max: 50)",
2413 | },
2414 | afterCursor: {
2415 | type: "string",
2416 | description: "Cursor for pagination (string ID)",
2417 | },
2418 | beforeCursor: {
2419 | type: "string",
2420 | description: "Cursor for previous page pagination (string ID)",
2421 | },
2422 | isDescending: {
2423 | type: "boolean",
2424 | description: "Sort in descending order (default: true)",
2425 | },
2426 | },
2427 | },
2428 | },
2429 | {
2430 | name: "get-proposal",
2431 | description: "Get detailed information about a specific proposal. You must provide either the Tally ID (globally unique) or both onchainId and governorId (unique within a governor).",
2432 | parameters: {
2433 | type: "object",
2434 | oneOf: [
2435 | {
2436 | required: ["id"],
2437 | properties: {
2438 | id: {
2439 | type: "string",
2440 | description: "The proposal's Tally ID (globally unique across all governors)",
2441 | },
2442 | includeArchived: {
2443 | type: "boolean",
2444 | description: "Include archived proposals",
2445 | },
2446 | isLatest: {
2447 | type: "boolean",
2448 | description: "Get the latest version of the proposal",
2449 | },
2450 | },
2451 | },
2452 | {
2453 | required: ["onchainId", "governorId"],
2454 | properties: {
2455 | onchainId: {
2456 | type: "string",
2457 | description: "The proposal's onchain ID (only unique within a governor)",
2458 | },
2459 | governorId: {
2460 | type: "string",
2461 | description: "The governor's ID (required when using onchainId)",
2462 | },
2463 | includeArchived: {
2464 | type: "boolean",
2465 | description: "Include archived proposals",
2466 | },
2467 | isLatest: {
2468 | type: "boolean",
2469 | description: "Get the latest version of the proposal",
2470 | },
2471 | },
2472 | },
2473 | ],
2474 | },
2475 | },
2476 | ];
2477 |
2478 | export class TallyService {
2479 | private client: GraphQLClient;
2480 | private static readonly DEFAULT_BASE_URL = 'https://api.tally.xyz/query';
2481 |
2482 | constructor(private config: TallyServiceConfig) {
2483 | this.client = new GraphQLClient(config.baseUrl || TallyService.DEFAULT_BASE_URL, {
2484 | headers: {
2485 | 'Api-Key': config.apiKey,
2486 | },
2487 | });
2488 | }
2489 |
2490 | static getOpenAIFunctionDefinitions(): OpenAIFunctionDefinition[] {
2491 | return OPENAI_FUNCTION_DEFINITIONS;
2492 | }
2493 |
2494 | /**
2495 | * Format a vote amount considering token decimals
2496 | * @param {string} votes - The raw vote amount
2497 | * @param {TokenInfo} token - Optional token info containing decimals and symbol
2498 | * @returns {string} Formatted vote amount with optional symbol
2499 | */
2500 | private static formatVotes(votes: string, token?: TokenInfo): string {
2501 | const val = BigInt(votes);
2502 | const decimals = token?.decimals ?? 18;
2503 | const denominator = BigInt(10 ** decimals);
2504 | const formatted = (Number(val) / Number(denominator)).toLocaleString();
2505 | return `${formatted}${token?.symbol ? ` ${token.symbol}` : ''}`;
2506 | }
2507 |
2508 | async listDAOs(params: ListDAOsParams = {}): Promise<OrganizationsResponse> {
2509 | return listDAOs(this.client, params);
2510 | }
2511 |
2512 | async getDAO(slug: string): Promise<Organization> {
2513 | return getDAO(this.client, slug);
2514 | }
2515 |
2516 | public async listDelegates(input: {
2517 | organizationId?: string;
2518 | organizationSlug?: string;
2519 | limit?: number;
2520 | afterCursor?: string;
2521 | beforeCursor?: string;
2522 | hasVotes?: boolean;
2523 | hasDelegators?: boolean;
2524 | isSeekingDelegation?: boolean;
2525 | }): Promise<{
2526 | delegates: Delegate[];
2527 | pageInfo: PageInfo;
2528 | }> {
2529 | return listDelegates(this.client, input);
2530 | }
2531 |
2532 | async getDelegators(params: GetDelegatorsParams): Promise<{
2533 | delegators: Delegation[];
2534 | pageInfo: PageInfo;
2535 | }> {
2536 | return getDelegators(this.client, params);
2537 | }
2538 |
2539 | async listProposals(input: ProposalsInput & { organizationSlug?: string }): Promise<ProposalsResponse> {
2540 | return listProposals(this.client, input);
2541 | }
2542 |
2543 | async getProposal(input: ProposalInput & { organizationSlug?: string }): Promise<ProposalDetailsResponse> {
2544 | return getProposal(this.client, input);
2545 | }
2546 |
2547 | // Keep the formatting utility functions in the service
2548 | static formatDAOList(daos: Organization[]): string {
2549 | return `Found ${daos.length} DAOs:\n\n` +
2550 | daos.map(dao =>
2551 | `${dao.name} (${dao.slug})\n` +
2552 | `Token Holders: ${dao.tokenOwnersCount}\n` +
2553 | `Delegates: ${dao.delegatesCount}\n` +
2554 | `Proposals: ${dao.proposalsCount}\n` +
2555 | `Active Proposals: ${dao.hasActiveProposals ? 'Yes' : 'No'}\n` +
2556 | `Description: ${dao.metadata?.description || 'No description available'}\n` +
2557 | `Website: ${dao.metadata?.websiteUrl || 'N/A'}\n` +
2558 | `Twitter: ${dao.metadata?.twitter || 'N/A'}\n` +
2559 | `Discord: ${dao.metadata?.discord || 'N/A'}\n` +
2560 | `GitHub: ${dao.metadata?.github || 'N/A'}\n` +
2561 | `Governance: ${dao.metadata?.governanceUrl || 'N/A'}\n` +
2562 | '---'
2563 | ).join('\n\n');
2564 | }
2565 |
2566 | static formatDAO(dao: Organization): string {
2567 | return `${dao.name} (${dao.slug})\n` +
2568 | `Token Holders: ${dao.tokenOwnersCount}\n` +
2569 | `Delegates: ${dao.delegatesCount}\n` +
2570 | `Proposals: ${dao.proposalsCount}\n` +
2571 | `Active Proposals: ${dao.hasActiveProposals ? 'Yes' : 'No'}\n` +
2572 | `Description: ${dao.metadata?.description || 'No description available'}\n` +
2573 | `Website: ${dao.metadata?.websiteUrl || 'N/A'}\n` +
2574 | `Twitter: ${dao.metadata?.twitter || 'N/A'}\n` +
2575 | `Discord: ${dao.metadata?.discord || 'N/A'}\n` +
2576 | `GitHub: ${dao.metadata?.github || 'N/A'}\n` +
2577 | `Governance: ${dao.metadata?.governanceUrl || 'N/A'}\n` +
2578 | `Chain IDs: ${dao.chainIds.join(', ')}\n` +
2579 | `Token IDs: ${dao.tokenIds?.join(', ') || 'N/A'}\n` +
2580 | `Governor IDs: ${dao.governorIds?.join(', ') || 'N/A'}`;
2581 | }
2582 |
2583 | static formatDelegatesList(delegates: Delegate[]): string {
2584 | return `Found ${delegates.length} delegates:\n\n` +
2585 | delegates.map(delegate =>
2586 | `${delegate.account.name || delegate.account.address}\n` +
2587 | `Address: ${delegate.account.address}\n` +
2588 | `Votes: ${delegate.votesCount}\n` +
2589 | `Delegators: ${delegate.delegatorsCount}\n` +
2590 | `Bio: ${delegate.account.bio || 'No bio available'}\n` +
2591 | `Statement: ${delegate.statement?.statementSummary || 'No statement available'}\n` +
2592 | '---'
2593 | ).join('\n\n');
2594 | }
2595 |
2596 | static formatDelegatorsList(delegators: Delegation[]): string {
2597 | return `Found ${delegators.length} delegators:\n\n` +
2598 | delegators.map(delegation =>
2599 | `${delegation.delegator.name || delegation.delegator.ens || delegation.delegator.address}\n` +
2600 | `Address: ${delegation.delegator.address}\n` +
2601 | `Votes: ${TallyService.formatVotes(delegation.votes, delegation.token)}\n` +
2602 | `Delegated at: Block ${delegation.blockNumber} (${new Date(delegation.blockTimestamp).toLocaleString()})\n` +
2603 | `${delegation.token ? `Token: ${delegation.token.symbol} (${delegation.token.name})\n` : ''}` +
2604 | '---'
2605 | ).join('\n\n');
2606 | }
2607 |
2608 | static formatProposalsList(proposals: ProposalsResponse['proposals']['nodes']): string {
2609 | return `Found ${proposals.length} proposals:\n\n` +
2610 | proposals.map(proposal =>
2611 | `${proposal.metadata.title}\n` +
2612 | `Tally ID: ${proposal.id}\n` +
2613 | `Onchain ID: ${proposal.onchainId}\n` +
2614 | `Status: ${proposal.status}\n` +
2615 | `Created: ${new Date(proposal.createdAt).toLocaleString()}\n` +
2616 | `Quorum: ${proposal.quorum}\n` +
2617 | `Organization: ${proposal.governor.organization.name} (${proposal.governor.organization.slug})\n` +
2618 | `Governor: ${proposal.governor.name}\n` +
2619 | `Vote Stats:\n${proposal.voteStats.map(stat =>
2620 | ` ${stat.type}: ${stat.percent.toFixed(2)}% (${stat.votesCount} votes from ${stat.votersCount} voters)`
2621 | ).join('\n')}\n` +
2622 | `Description: ${proposal.metadata.description.slice(0, 200)}${proposal.metadata.description.length > 200 ? '...' : ''}\n` +
2623 | '---'
2624 | ).join('\n\n');
2625 | }
2626 |
2627 | static formatProposal(proposal: ProposalDetailsResponse['proposal']): string {
2628 | return `${proposal.metadata.title}\n` +
2629 | `Tally ID: ${proposal.id}\n` +
2630 | `Onchain ID: ${proposal.onchainId}\n` +
2631 | `Status: ${proposal.status}\n` +
2632 | `Quorum: ${proposal.quorum}\n` +
2633 | `Organization: ${proposal.governor.organization.name} (${proposal.governor.organization.slug})\n` +
2634 | `Governor: ${proposal.governor.name}\n` +
2635 | `Proposer: ${proposal.proposer.name || proposal.proposer.address}\n` +
2636 | `Vote Stats:\n${proposal.voteStats.map(stat =>
2637 | ` ${stat.type}: ${stat.percent.toFixed(2)}% (${stat.votesCount} votes from ${stat.votersCount} voters)`
2638 | ).join('\n')}\n` +
2639 | `Description:\n${proposal.metadata.description}\n` +
2640 | `Links:\n` +
2641 | ` Discourse: ${proposal.metadata.discourseURL || 'N/A'}\n` +
2642 | ` Snapshot: ${proposal.metadata.snapshotURL || 'N/A'}`;
2643 | }
2644 | }
2645 |
2646 | ================
2647 | File: index.ts
2648 | ================
2649 | #!/usr/bin/env node
2650 | import * as dotenv from 'dotenv';
2651 | import { TallyServer } from './server.js';
2652 |
2653 | // Load environment variables
2654 | dotenv.config();
2655 |
2656 | const apiKey = process.env.TALLY_API_KEY;
2657 | if (!apiKey) {
2658 | console.error("Error: TALLY_API_KEY environment variable is required");
2659 | process.exit(1);
2660 | }
2661 |
2662 | // Create and start the server
2663 | const server = new TallyServer(apiKey);
2664 | server.start().catch((error) => {
2665 | console.error("Fatal error:", error);
2666 | process.exit(1);
2667 | });
2668 |
2669 | ================
2670 | File: server.ts
2671 | ================
2672 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2673 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2674 | import {
2675 | ListToolsRequestSchema,
2676 | CallToolRequestSchema,
2677 | type Tool,
2678 | type TextContent
2679 | } from "@modelcontextprotocol/sdk/types.js";
2680 | import { TallyService } from './services/tally.service.js';
2681 | import type { OrganizationsSortBy } from './services/organizations/organizations.types.js';
2682 |
2683 | export class TallyServer {
2684 | private server: Server;
2685 | private service: TallyService;
2686 |
2687 | constructor(apiKey: string) {
2688 | // Initialize service
2689 | this.service = new TallyService({ apiKey });
2690 |
2691 | // Create server instance
2692 | this.server = new Server(
2693 | {
2694 | name: "tally-api",
2695 | version: "1.0.0",
2696 | },
2697 | {
2698 | capabilities: {
2699 | tools: {},
2700 | },
2701 | }
2702 | );
2703 |
2704 | this.setupHandlers();
2705 | }
2706 |
2707 | private setupHandlers() {
2708 | // List available tools
2709 | this.server.setRequestHandler(ListToolsRequestSchema, async () => {
2710 | const tools: Tool[] = [
2711 | {
2712 | name: "list-daos",
2713 | description: "List DAOs on Tally sorted by specified criteria",
2714 | inputSchema: {
2715 | type: "object",
2716 | properties: {
2717 | limit: {
2718 | type: "number",
2719 | description: "Maximum number of DAOs to return (default: 20, max: 50)",
2720 | },
2721 | afterCursor: {
2722 | type: "string",
2723 | description: "Cursor for pagination",
2724 | },
2725 | sortBy: {
2726 | type: "string",
2727 | enum: ["id", "name", "explore", "popular"],
2728 | description: "How to sort the DAOs (default: popular). 'explore' prioritizes DAOs with live proposals",
2729 | },
2730 | },
2731 | },
2732 | },
2733 | {
2734 | name: "get-dao",
2735 | description: "Get detailed information about a specific DAO",
2736 | inputSchema: {
2737 | type: "object",
2738 | required: ["slug"],
2739 | properties: {
2740 | slug: {
2741 | type: "string",
2742 | description: "The DAO's slug (e.g., 'uniswap' or 'aave')",
2743 | },
2744 | },
2745 | },
2746 | },
2747 | {
2748 | name: "list-delegates",
2749 | description: "List delegates for a specific organization with their metadata",
2750 | inputSchema: {
2751 | type: "object",
2752 | required: ["organizationIdOrSlug"],
2753 | properties: {
2754 | organizationIdOrSlug: {
2755 | type: "string",
2756 | description: "The organization's ID or slug (e.g., 'arbitrum' or 'eip155:1:123')",
2757 | },
2758 | limit: {
2759 | type: "number",
2760 | description: "Maximum number of delegates to return (default: 20, max: 50)",
2761 | },
2762 | afterCursor: {
2763 | type: "string",
2764 | description: "Cursor for pagination",
2765 | },
2766 | hasVotes: {
2767 | type: "boolean",
2768 | description: "Filter for delegates with votes",
2769 | },
2770 | hasDelegators: {
2771 | type: "boolean",
2772 | description: "Filter for delegates with delegators",
2773 | },
2774 | isSeekingDelegation: {
2775 | type: "boolean",
2776 | description: "Filter for delegates seeking delegation",
2777 | },
2778 | },
2779 | },
2780 | },
2781 | {
2782 | name: "get-delegators",
2783 | description: "Get list of delegators for a specific address",
2784 | inputSchema: {
2785 | type: "object",
2786 | required: ["address"],
2787 | properties: {
2788 | address: {
2789 | type: "string",
2790 | description: "The Ethereum address to get delegators for (0x format)",
2791 | },
2792 | organizationId: {
2793 | type: "string",
2794 | description: "Filter by specific organization ID",
2795 | },
2796 | organizationSlug: {
2797 | type: "string",
2798 | description: "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId",
2799 | },
2800 | governorId: {
2801 | type: "string",
2802 | description: "Filter by specific governor ID",
2803 | },
2804 | limit: {
2805 | type: "number",
2806 | description: "Maximum number of delegators to return (default: 20, max: 50)",
2807 | },
2808 | afterCursor: {
2809 | type: "string",
2810 | description: "Cursor for pagination",
2811 | },
2812 | beforeCursor: {
2813 | type: "string",
2814 | description: "Cursor for previous page pagination",
2815 | },
2816 | sortBy: {
2817 | type: "string",
2818 | enum: ["id", "votes"],
2819 | description: "How to sort the delegators (default: id)",
2820 | },
2821 | isDescending: {
2822 | type: "boolean",
2823 | description: "Sort in descending order (default: true)",
2824 | },
2825 | },
2826 | },
2827 | },
2828 | {
2829 | name: "list-proposals",
2830 | description: "List proposals for a specific organization or governor",
2831 | inputSchema: {
2832 | type: "object",
2833 | properties: {
2834 | organizationId: {
2835 | type: "string",
2836 | description: "Filter by organization ID (large integer as string)"
2837 | },
2838 | organizationSlug: {
2839 | type: "string",
2840 | description: "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId"
2841 | },
2842 | governorId: {
2843 | type: "string",
2844 | description: "Filter by governor ID"
2845 | },
2846 | includeArchived: {
2847 | type: "boolean",
2848 | description: "Include archived proposals"
2849 | },
2850 | isDraft: {
2851 | type: "boolean",
2852 | description: "Filter for draft proposals"
2853 | },
2854 | limit: {
2855 | type: "number",
2856 | description: "Maximum number of proposals to return (default: 20, max: 50)"
2857 | },
2858 | afterCursor: {
2859 | type: "string",
2860 | description: "Cursor for pagination (string ID)"
2861 | },
2862 | beforeCursor: {
2863 | type: "string",
2864 | description: "Cursor for previous page pagination (string ID)"
2865 | },
2866 | isDescending: {
2867 | type: "boolean",
2868 | description: "Sort in descending order (default: true)"
2869 | },
2870 | },
2871 | },
2872 | },
2873 | {
2874 | name: "get-proposal",
2875 | description: "Get detailed information about a specific proposal. You must provide either the Tally ID (globally unique) or both onchainId and governorId (unique within a governor).",
2876 | inputSchema: {
2877 | type: "object",
2878 | oneOf: [
2879 | {
2880 | required: ["id"],
2881 | properties: {
2882 | id: {
2883 | type: "string",
2884 | description: "The proposal's Tally ID (globally unique across all governors)"
2885 | },
2886 | includeArchived: {
2887 | type: "boolean",
2888 | description: "Include archived proposals"
2889 | },
2890 | isLatest: {
2891 | type: "boolean",
2892 | description: "Get the latest version of the proposal"
2893 | }
2894 | }
2895 | },
2896 | {
2897 | required: ["onchainId", "governorId"],
2898 | properties: {
2899 | onchainId: {
2900 | type: "string",
2901 | description: "The proposal's onchain ID (only unique within a governor)"
2902 | },
2903 | governorId: {
2904 | type: "string",
2905 | description: "The governor's ID (required when using onchainId)"
2906 | },
2907 | includeArchived: {
2908 | type: "boolean",
2909 | description: "Include archived proposals"
2910 | },
2911 | isLatest: {
2912 | type: "boolean",
2913 | description: "Get the latest version of the proposal"
2914 | }
2915 | }
2916 | }
2917 | ]
2918 | },
2919 | },
2920 | ];
2921 |
2922 | return { tools };
2923 | });
2924 |
2925 | // Handle tool execution
2926 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2927 | const { name, arguments: args = {} } = request.params;
2928 |
2929 | if (name === "list-daos") {
2930 | try {
2931 | const data = await this.service.listDAOs({
2932 | limit: typeof args.limit === 'number' ? args.limit : undefined,
2933 | afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor : undefined,
2934 | sortBy: typeof args.sortBy === 'string' ? args.sortBy as OrganizationsSortBy : undefined,
2935 | });
2936 |
2937 | const content: TextContent[] = [
2938 | {
2939 | type: "text",
2940 | text: TallyService.formatDAOList(data.organizations.nodes)
2941 | }
2942 | ];
2943 |
2944 | return { content };
2945 | } catch (error) {
2946 | throw new Error(`Error fetching DAOs: ${error instanceof Error ? error.message : 'Unknown error'}`);
2947 | }
2948 | }
2949 |
2950 | if (name === "get-dao") {
2951 | try {
2952 | if (typeof args.slug !== 'string') {
2953 | throw new Error('slug must be a string');
2954 | }
2955 |
2956 | const data = await this.service.getDAO(args.slug);
2957 | const content: TextContent[] = [
2958 | {
2959 | type: "text",
2960 | text: TallyService.formatDAO(data)
2961 | }
2962 | ];
2963 |
2964 | return { content };
2965 | } catch (error) {
2966 | throw new Error(`Error fetching DAO: ${error instanceof Error ? error.message : 'Unknown error'}`);
2967 | }
2968 | }
2969 |
2970 | if (name === "list-delegates") {
2971 | try {
2972 | if (typeof args.organizationIdOrSlug !== 'string') {
2973 | throw new Error('organizationIdOrSlug must be a string');
2974 | }
2975 |
2976 | // Determine if the input is an ID or slug
2977 | // If it contains 'eip155' or is numeric, treat as ID, otherwise as slug
2978 | const isId = args.organizationIdOrSlug.includes('eip155') || /^\d+$/.test(args.organizationIdOrSlug);
2979 |
2980 | const data = await this.service.listDelegates({
2981 | ...(isId ? { organizationId: args.organizationIdOrSlug } : { organizationSlug: args.organizationIdOrSlug }),
2982 | limit: typeof args.limit === 'number' ? args.limit : undefined,
2983 | afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor : undefined,
2984 | hasVotes: typeof args.hasVotes === 'boolean' ? args.hasVotes : undefined,
2985 | hasDelegators: typeof args.hasDelegators === 'boolean' ? args.hasDelegators : undefined,
2986 | isSeekingDelegation: typeof args.isSeekingDelegation === 'boolean' ? args.isSeekingDelegation : undefined,
2987 | });
2988 |
2989 | const content: TextContent[] = [
2990 | {
2991 | type: "text",
2992 | text: TallyService.formatDelegatesList(data.delegates)
2993 | }
2994 | ];
2995 |
2996 | return { content };
2997 | } catch (error) {
2998 | throw new Error(`Error fetching delegates: ${error instanceof Error ? error.message : 'Unknown error'}`);
2999 | }
3000 | }
3001 |
3002 | if (name === "get-delegators") {
3003 | try {
3004 | if (typeof args.address !== 'string') {
3005 | throw new Error('address must be a string');
3006 | }
3007 |
3008 | const data = await this.service.getDelegators({
3009 | address: args.address,
3010 | organizationId: typeof args.organizationId === 'string' ? args.organizationId : undefined,
3011 | organizationSlug: typeof args.organizationSlug === 'string' ? args.organizationSlug : undefined,
3012 | governorId: typeof args.governorId === 'string' ? args.governorId : undefined,
3013 | limit: typeof args.limit === 'number' ? args.limit : undefined,
3014 | afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor : undefined,
3015 | beforeCursor: typeof args.beforeCursor === 'string' ? args.beforeCursor : undefined,
3016 | sortBy: typeof args.sortBy === 'string' ? args.sortBy as 'id' | 'votes' : undefined,
3017 | isDescending: typeof args.isDescending === 'boolean' ? args.isDescending : undefined,
3018 | });
3019 |
3020 | const content: TextContent[] = [
3021 | {
3022 | type: "text",
3023 | text: TallyService.formatDelegatorsList(data.delegators)
3024 | }
3025 | ];
3026 |
3027 | return { content };
3028 | } catch (error) {
3029 | throw new Error(`Error fetching delegators: ${error instanceof Error ? error.message : 'Unknown error'}`);
3030 | }
3031 | }
3032 |
3033 | if (name === "list-proposals") {
3034 | try {
3035 | const data = await this.service.listProposals({
3036 | filters: {
3037 | organizationId: typeof args.organizationId === 'string' ? args.organizationId.toString() : undefined,
3038 | governorId: typeof args.governorId === 'string' ? args.governorId : undefined,
3039 | includeArchived: typeof args.includeArchived === 'boolean' ? args.includeArchived : undefined,
3040 | isDraft: typeof args.isDraft === 'boolean' ? args.isDraft : undefined,
3041 | },
3042 | organizationSlug: typeof args.organizationSlug === 'string' ? args.organizationSlug : undefined,
3043 | page: {
3044 | limit: typeof args.limit === 'number' ? args.limit : undefined,
3045 | afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor.toString() : undefined,
3046 | beforeCursor: typeof args.beforeCursor === 'string' ? args.beforeCursor.toString() : undefined,
3047 | },
3048 | sort: typeof args.isDescending === 'boolean' ? {
3049 | isDescending: args.isDescending,
3050 | sortBy: "id"
3051 | } : undefined
3052 | });
3053 |
3054 | const content: TextContent[] = [
3055 | {
3056 | type: "text",
3057 | text: TallyService.formatProposalsList(data.proposals.nodes)
3058 | }
3059 | ];
3060 |
3061 | return { content };
3062 | } catch (error) {
3063 | throw new Error(`Error fetching proposals: ${error instanceof Error ? error.message : 'Unknown error'}`);
3064 | }
3065 | }
3066 |
3067 | if (name === "get-proposal") {
3068 | try {
3069 | // If we have just an ID, we can use it directly
3070 | if (typeof args.id === 'string') {
3071 | const data = await this.service.getProposal({
3072 | id: args.id,
3073 | includeArchived: typeof args.includeArchived === 'boolean' ? args.includeArchived : undefined,
3074 | isLatest: typeof args.isLatest === 'boolean' ? args.isLatest : undefined,
3075 | });
3076 | return {
3077 | content: [{
3078 | type: "text",
3079 | text: TallyService.formatProposal(data.proposal)
3080 | }]
3081 | };
3082 | }
3083 |
3084 | // If we have onchainId and governorId, use them together
3085 | if (typeof args.onchainId === 'string' && typeof args.governorId === 'string') {
3086 | const data = await this.service.getProposal({
3087 | onchainId: args.onchainId,
3088 | governorId: args.governorId,
3089 | includeArchived: typeof args.includeArchived === 'boolean' ? args.includeArchived : undefined,
3090 | isLatest: typeof args.isLatest === 'boolean' ? args.isLatest : undefined,
3091 | });
3092 | return {
3093 | content: [{
3094 | type: "text",
3095 | text: TallyService.formatProposal(data.proposal)
3096 | }]
3097 | };
3098 | }
3099 |
3100 | throw new Error('Must provide either id or both onchainId and governorId');
3101 | } catch (error) {
3102 | throw new Error(`Error fetching proposal: ${error instanceof Error ? error.message : 'Unknown error'}`);
3103 | }
3104 | }
3105 |
3106 | throw new Error(`Unknown tool: ${name}`);
3107 | });
3108 | }
3109 |
3110 | async start() {
3111 | const transport = new StdioServerTransport();
3112 | await this.server.connect(transport);
3113 | console.error("Tally MCP Server running on stdio");
3114 | }
3115 | }
3116 |
```