#
tokens: 32123/50000 1/103 files (page 5/5)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 5/5FirstPrevNextLast