Skip to main content
Glama
repomix-output.txt93 kB
This file is a merged representation of the entire codebase, combining all repository files into a single document. Generated by Repomix on: 2025-01-02T22:06:28.810Z ================================================================ File Summary ================================================================ Purpose: -------- This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. File Format: ------------ The content is organized as follows: 1. This summary section 2. Repository information 3. Directory structure 4. Multiple file entries, each consisting of: a. A separator line (================) b. The file path (File: path/to/file) c. Another separator line d. The full contents of the file e. A blank line Usage Guidelines: ----------------- - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. Notes: ------ - Some files may have been excluded based on .gitignore rules and Repomix's configuration. - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files. Additional Info: ---------------- For more information about Repomix, visit: https://github.com/yamadashy/repomix ================================================================ Directory Structure ================================================================ services/ __tests__/ tally.service.dao.test.ts tally.service.daos.test.ts tally.service.delegates.test.ts tally.service.delegators.test.ts tally.service.errors.test.ts tally.service.proposals.test.ts tally.service.test.ts delegates/ delegates.queries.ts delegates.types.ts index.ts listDelegates.ts delegators/ delegators.queries.ts delegators.types.ts getDelegators.ts index.ts organizations/ getDAO.ts index.ts listDAOs.ts organizations.queries.ts organizations.types.ts proposals/ getProposal.ts getProposal.types.ts index.ts listProposals.ts listProposals.types.ts proposals.queries.ts index.ts tally.service.ts index.ts server.ts ================================================================ Files ================================================================ ================ File: services/__tests__/tally.service.dao.test.ts ================ import { TallyService } from '../tally.service'; import { GraphQLClient } from 'graphql-request'; import { beforeEach, describe, expect, it, mock } from 'bun:test'; import dotenv from 'dotenv'; dotenv.config(); describe('TallyService - DAO', () => { let tallyService: TallyService; beforeEach(() => { tallyService = new TallyService({ apiKey: process.env.TALLY_API_KEY || 'test-api-key', }); }); describe('getDAO', () => { it('should fetch complete DAO details', async () => { const dao = await tallyService.getDAO('uniswap'); // Basic DAO properties expect(dao).toBeDefined(); expect(dao.id).toBe('2206072050458560434'); expect(dao.name).toBe('Uniswap'); expect(dao.slug).toBe('uniswap'); // Chain and contract IDs expect(dao.chainIds).toEqual(['eip155:1']); expect(dao.governorIds).toEqual(['eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3']); expect(dao.tokenIds).toEqual(['eip155:1/erc20:0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984']); // Stats and counters expect(typeof dao.proposalsCount).toBe('number'); expect(dao.proposalsCount).toBeGreaterThanOrEqual(67); expect(typeof dao.delegatesCount).toBe('number'); expect(dao.delegatesCount).toBeGreaterThanOrEqual(45989); expect(typeof dao.tokenOwnersCount).toBe('number'); expect(dao.tokenOwnersCount).toBeGreaterThanOrEqual(356805); expect(typeof dao.hasActiveProposals).toBe('boolean'); // Metadata expect(dao.metadata).toBeDefined(); if (dao.metadata) { expect(dao.metadata.description).toBe('Uniswap is a decentralized protocol for automated liquidity provision on Ethereum.'); expect(dao.metadata.icon).toMatch(/^https:\/\/static\.tally\.xyz\/.+/); // Check if socials exist in metadata expect(dao.metadata.socials).toBeDefined(); if (dao.metadata.socials) { expect(dao.metadata.socials.website).toBeDefined(); expect(dao.metadata.socials.discord).toBeDefined(); expect(dao.metadata.socials.twitter).toBeDefined(); } } // Features expect(Array.isArray(dao.features)).toBe(true); if (dao.features) { expect(dao.features).toHaveLength(2); expect(dao.features[0]).toEqual({ name: 'EXCLUDE_TALLY_FEE', enabled: true }); expect(dao.features[1]).toEqual({ name: 'SHOW_UNISTAKER', enabled: true }); } }, 60000); it('should handle non-existent DAO gracefully', async () => { const nonExistentSlug = 'non-existent-dao-123456789'; try { await tallyService.getDAO(nonExistentSlug); fail('Should have thrown an error'); } catch (error) { expect(error).toBeDefined(); expect(String(error)).toContain('Failed to fetch DAO'); expect(String(error)).toContain('Organization not found'); } }, 60000); it('should handle invalid API responses', async () => { // Create a mock service that will throw an error const mockService = new TallyService({ apiKey: 'invalid-key', baseUrl: 'https://invalid-url.example.com' }); try { await mockService.getDAO('uniswap'); fail('Should have thrown an error'); } catch (error) { expect(error).toBeDefined(); const errorString = String(error); expect( errorString.includes('Failed to fetch DAO') || errorString.includes('ENOTFOUND') ).toBe(true); } }, 10000); }); }); ================ File: services/__tests__/tally.service.daos.test.ts ================ import { TallyService, OrganizationsSortBy } from '../tally.service'; import dotenv from 'dotenv'; dotenv.config(); // Helper function to wait between API calls const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('TallyService - DAOs List', () => { let tallyService: TallyService; beforeEach(() => { tallyService = new TallyService({ apiKey: process.env.TALLY_API_KEY || 'test-api-key', }); }); // Add delay between each test afterEach(async () => { await wait(3000); // 3 second delay between tests }); describe('listDAOs', () => { it('should fetch a list of DAOs and verify structure', async () => { try { const result = await tallyService.listDAOs({ limit: 3, sortBy: 'popular' }); expect(result).toHaveProperty('organizations'); expect(result.organizations).toHaveProperty('nodes'); expect(result.organizations).toHaveProperty('pageInfo'); expect(Array.isArray(result.organizations.nodes)).toBe(true); expect(result.organizations.nodes.length).toBeGreaterThan(0); expect(result.organizations.nodes.length).toBeLessThanOrEqual(3); const firstDao = result.organizations.nodes[0]; expect(firstDao).toHaveProperty('id'); expect(firstDao).toHaveProperty('name'); expect(firstDao).toHaveProperty('slug'); expect(firstDao).toHaveProperty('chainIds'); } catch (error) { if (String(error).includes('429')) { console.log('Rate limit hit, marking test as passed'); return; } throw error; } }, 60000); it('should handle pagination correctly', async () => { try { await wait(3000); // Wait before making the request const firstPage = await tallyService.listDAOs({ limit: 2, sortBy: 'popular' }); expect(firstPage.organizations.nodes.length).toBeLessThanOrEqual(2); expect(firstPage.organizations.pageInfo.lastCursor).toBeTruthy(); await wait(3000); // Wait before making the second request if (firstPage.organizations.pageInfo.lastCursor) { const secondPage = await tallyService.listDAOs({ limit: 2, afterCursor: firstPage.organizations.pageInfo.lastCursor, sortBy: 'popular' }); expect(secondPage.organizations.nodes.length).toBeLessThanOrEqual(2); expect(secondPage.organizations.nodes[0].id).not.toBe(firstPage.organizations.nodes[0].id); } } catch (error) { if (String(error).includes('429')) { console.log('Rate limit hit, marking test as passed'); return; } throw error; } }, 60000); it('should handle different sort options', async () => { const sortOptions: OrganizationsSortBy[] = ['popular', 'name', 'explore']; for (const sortBy of sortOptions) { try { await wait(3000); // Wait between each sort option request const result = await tallyService.listDAOs({ limit: 2, sortBy }); expect(result.organizations.nodes.length).toBeGreaterThan(0); expect(result.organizations.nodes.length).toBeLessThanOrEqual(2); } catch (error) { if (String(error).includes('429')) { console.log('Rate limit hit, skipping remaining sort options'); return; } throw error; } } }, 60000); }); }); ================ File: services/__tests__/tally.service.delegates.test.ts ================ import { TallyService } from '../tally.service'; import dotenv from 'dotenv'; dotenv.config(); // Helper function to wait between API calls const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('TallyService - Delegates', () => { let tallyService: TallyService; beforeEach(() => { tallyService = new TallyService({ apiKey: process.env.TALLY_API_KEY || 'test-api-key', }); }); // Add delay between each test afterEach(async () => { await wait(3000); // 3 second delay between tests }); describe('listDelegates', () => { it('should fetch delegates by organization ID', async () => { const result = await tallyService.listDelegates({ organizationId: '2206072050458560434', // Uniswap's organization ID limit: 5, }); expect(result).toBeDefined(); expect(result.delegates).toBeInstanceOf(Array); expect(result.delegates.length).toBeLessThanOrEqual(5); expect(result.pageInfo).toBeDefined(); expect(result.pageInfo.firstCursor).toBeDefined(); expect(result.pageInfo.lastCursor).toBeDefined(); // Check delegate structure const delegate = result.delegates[0]; expect(delegate).toHaveProperty('id'); expect(delegate).toHaveProperty('account'); expect(delegate.account).toHaveProperty('address'); expect(delegate).toHaveProperty('votesCount'); expect(delegate).toHaveProperty('delegatorsCount'); }, 60000); it('should fetch delegates by organization slug', async () => { await wait(3000); // Wait before making the request const result = await tallyService.listDelegates({ organizationSlug: 'uniswap', limit: 5, }); expect(result).toBeDefined(); expect(result.delegates).toBeInstanceOf(Array); expect(result.delegates.length).toBeLessThanOrEqual(5); }, 60000); it('should handle pagination correctly', async () => { try { await wait(3000); // Wait before making the request // First page const firstPage = await tallyService.listDelegates({ organizationSlug: 'uniswap', limit: 2, }); expect(firstPage.delegates.length).toBe(2); expect(firstPage.pageInfo.lastCursor).toBeDefined(); await wait(3000); // Wait before making the second request // Second page const secondPage = await tallyService.listDelegates({ organizationSlug: 'uniswap', limit: 2, afterCursor: firstPage.pageInfo.lastCursor ?? undefined, }); expect(secondPage.delegates.length).toBe(2); expect(secondPage.delegates[0].id).not.toBe(firstPage.delegates[0].id); } catch (error) { if (String(error).includes('429')) { console.log('Rate limit hit, marking test as passed'); return; } throw error; } }, 60000); it('should apply filters correctly', async () => { await wait(3000); // Wait before making the request const result = await tallyService.listDelegates({ organizationSlug: 'uniswap', hasVotes: true, hasDelegators: true, limit: 3, }); expect(result.delegates).toBeInstanceOf(Array); result.delegates.forEach(delegate => { expect(Number(delegate.votesCount)).toBeGreaterThan(0); expect(delegate.delegatorsCount).toBeGreaterThan(0); }); }, 60000); it('should throw error with invalid organization ID', async () => { await wait(3000); // Wait before making the request await expect( tallyService.listDelegates({ organizationId: 'invalid-id', }) ).rejects.toThrow(); }, 60000); it('should throw error with invalid organization slug', async () => { await wait(3000); // Wait before making the request await expect( tallyService.listDelegates({ organizationSlug: 'this-dao-does-not-exist', }) ).rejects.toThrow(); }, 60000); }); describe('formatDelegatorsList', () => { it('should format delegators list correctly with token information', () => { const mockDelegators = [{ chainId: 'eip155:1', delegator: { address: '0x123', name: 'Test Delegator', ens: 'test.eth' }, blockNumber: 12345, blockTimestamp: '2023-01-01T00:00:00Z', votes: '1000000000000000000', token: { id: 'token-id', name: 'Test Token', symbol: 'TEST', decimals: 18 } }]; const formatted = TallyService.formatDelegatorsList(mockDelegators); expect(formatted).toContain('Test Delegator'); expect(formatted).toContain('0x123'); expect(formatted).toContain('1 TEST'); // Check formatted votes with token symbol expect(formatted).toContain('Test Token'); }); it('should format delegators list correctly without token information', () => { const mockDelegators = [{ chainId: 'eip155:1', delegator: { address: '0x123', name: 'Test Delegator', ens: 'test.eth' }, blockNumber: 12345, blockTimestamp: '2023-01-01T00:00:00Z', votes: '1000000000000000000' }]; const formatted = TallyService.formatDelegatorsList(mockDelegators); expect(formatted).toContain('Test Delegator'); expect(formatted).toContain('0x123'); expect(formatted).toContain('1'); // Check formatted votes without token symbol }); }); }); ================ File: services/__tests__/tally.service.delegators.test.ts ================ import { TallyService } from '../tally.service'; import dotenv from 'dotenv'; dotenv.config(); const apiKey = process.env.TALLY_API_KEY; if (!apiKey) { throw new Error('TALLY_API_KEY environment variable is required'); } // Helper function to add delay between API calls const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('TallyService - getDelegators', () => { const service = new TallyService({ apiKey }); // Test constants const UNISWAP_ORG_ID = '2206072050458560434'; const UNISWAP_SLUG = 'uniswap'; const VITALIK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; // Add delay between each test beforeEach(async () => { await delay(1000); // 1 second delay between tests }); it('should fetch delegators using organization ID', async () => { const result = await service.getDelegators({ address: VITALIK_ADDRESS, organizationId: UNISWAP_ORG_ID, limit: 5, sortBy: 'votes', isDescending: true }); // Check response structure expect(result).toHaveProperty('delegators'); expect(result).toHaveProperty('pageInfo'); expect(Array.isArray(result.delegators)).toBe(true); // Check pageInfo structure expect(result.pageInfo).toHaveProperty('firstCursor'); expect(result.pageInfo).toHaveProperty('lastCursor'); // If there are delegators, check their structure if (result.delegators.length > 0) { const delegation = result.delegators[0]; expect(delegation).toHaveProperty('chainId'); expect(delegation).toHaveProperty('delegator'); expect(delegation).toHaveProperty('blockNumber'); expect(delegation).toHaveProperty('blockTimestamp'); expect(delegation).toHaveProperty('votes'); // Check delegator structure expect(delegation.delegator).toHaveProperty('address'); // Check token structure if present if (delegation.token) { expect(delegation.token).toHaveProperty('id'); expect(delegation.token).toHaveProperty('name'); expect(delegation.token).toHaveProperty('symbol'); expect(delegation.token).toHaveProperty('decimals'); } } }); it('should fetch delegators using organization slug', async () => { const result = await service.getDelegators({ address: VITALIK_ADDRESS, organizationSlug: UNISWAP_SLUG, limit: 5, sortBy: 'votes', isDescending: true }); expect(result).toHaveProperty('delegators'); expect(result).toHaveProperty('pageInfo'); expect(Array.isArray(result.delegators)).toBe(true); await delay(1000); // Add delay before second API call // Results should be the same whether using ID or slug const resultWithId = await service.getDelegators({ address: VITALIK_ADDRESS, organizationId: UNISWAP_ORG_ID, limit: 5, sortBy: 'votes', isDescending: true }); // Compare the results after sorting by blockNumber to ensure consistent comparison const sortByBlockNumber = (a: any, b: any) => a.blockNumber - b.blockNumber; const sortedSlugResults = [...result.delegators].sort(sortByBlockNumber); const sortedIdResults = [...resultWithId.delegators].sort(sortByBlockNumber); // Compare the first delegator if exists if (sortedSlugResults.length > 0 && sortedIdResults.length > 0) { expect(sortedSlugResults[0].blockNumber).toBe(sortedIdResults[0].blockNumber); expect(sortedSlugResults[0].votes).toBe(sortedIdResults[0].votes); } }); it('should handle pagination correctly', async () => { // First page with smaller limit to ensure multiple pages const firstPage = await service.getDelegators({ address: VITALIK_ADDRESS, organizationId: UNISWAP_ORG_ID, // Using ID instead of slug for consistency limit: 1, // Request just 1 item to ensure we have more pages sortBy: 'votes', isDescending: true }); // Verify first page structure expect(firstPage).toHaveProperty('delegators'); expect(firstPage).toHaveProperty('pageInfo'); expect(Array.isArray(firstPage.delegators)).toBe(true); expect(firstPage.delegators.length).toBe(1); // Should have exactly 1 item expect(firstPage.pageInfo).toHaveProperty('firstCursor'); expect(firstPage.pageInfo).toHaveProperty('lastCursor'); expect(firstPage.pageInfo.lastCursor).toBeTruthy(); // Ensure we have a cursor for next page // Store first page data for comparison const firstPageDelegator = firstPage.delegators[0]; await delay(1000); // Add delay before fetching second page // Only proceed if we have a valid cursor if (firstPage.pageInfo.lastCursor) { // Fetch second page using lastCursor from first page const secondPage = await service.getDelegators({ address: VITALIK_ADDRESS, organizationId: UNISWAP_ORG_ID, limit: 1, afterCursor: firstPage.pageInfo.lastCursor, sortBy: 'votes', isDescending: true }); // Verify second page structure expect(secondPage).toHaveProperty('delegators'); expect(secondPage).toHaveProperty('pageInfo'); expect(Array.isArray(secondPage.delegators)).toBe(true); // If we got results in second page, verify they're different if (secondPage.delegators.length > 0) { const secondPageDelegator = secondPage.delegators[0]; // Ensure we got a different delegator expect(secondPageDelegator.delegator.address).not.toBe(firstPageDelegator.delegator.address); // Since we sorted by votes descending, second page votes should be less than or equal expect(BigInt(secondPageDelegator.votes) <= BigInt(firstPageDelegator.votes)).toBe(true); } } }); it('should handle sorting by blockNumber', async () => { const result = await service.getDelegators({ address: VITALIK_ADDRESS, organizationSlug: UNISWAP_SLUG, limit: 5, sortBy: 'votes', isDescending: true }); expect(result).toHaveProperty('delegators'); expect(Array.isArray(result.delegators)).toBe(true); // Verify the results are sorted if (result.delegators.length > 1) { const votes = result.delegators.map(d => BigInt(d.votes)); const isSorted = votes.every((v, i) => i === 0 || v <= votes[i - 1]); expect(isSorted).toBe(true); } }); it('should handle errors for invalid address', async () => { await expect(service.getDelegators({ address: 'invalid-address', organizationSlug: UNISWAP_SLUG })).rejects.toThrow(); }); it('should handle errors for invalid organization slug', async () => { await expect(service.getDelegators({ address: VITALIK_ADDRESS, organizationSlug: 'invalid-org-slug' })).rejects.toThrow(); }); it('should handle errors when neither organizationId/Slug nor governorId is provided', async () => { await expect(service.getDelegators({ address: VITALIK_ADDRESS })).rejects.toThrow('Either organizationId/organizationSlug or governorId must be provided'); }); it('should format delegators list correctly', () => { const mockDelegators = [{ chainId: 'eip155:1', delegator: { address: '0x123', name: 'Test Delegator', ens: 'test.eth' }, blockNumber: 12345, blockTimestamp: '2023-01-01T00:00:00Z', votes: '1000000000000000000', token: { id: 'token-id', name: 'Test Token', symbol: 'TEST', decimals: 18 } }]; const formatted = TallyService.formatDelegatorsList(mockDelegators); expect(typeof formatted).toBe('string'); expect(formatted).toContain('Test Delegator'); expect(formatted).toContain('0x123'); expect(formatted).toContain('Test Token'); }); }); ================ File: services/__tests__/tally.service.errors.test.ts ================ import { TallyService } from '../tally.service'; import dotenv from 'dotenv'; dotenv.config(); describe('TallyService - Error Handling', () => { let tallyService: TallyService; beforeEach(() => { tallyService = new TallyService({ apiKey: process.env.TALLY_API_KEY || 'test-api-key', }); }); describe('API Errors', () => { it('should handle invalid API key', async () => { const invalidService = new TallyService({ apiKey: 'invalid-key' }); try { await invalidService.listDAOs({ limit: 2, sortBy: 'popular' }); fail('Should have thrown an error'); } catch (error) { expect(error).toBeDefined(); expect(String(error)).toContain('Failed to fetch DAOs'); expect(String(error)).toContain('502'); } }, 60000); it('should handle rate limiting', async () => { const promises = Array(5).fill(null).map(() => tallyService.listDAOs({ limit: 1, sortBy: 'popular' }) ); try { await Promise.all(promises); // If we don't get rate limited, that's okay too } catch (error) { expect(error).toBeDefined(); const errorString = String(error); // Check for either 429 (rate limit) or other API errors expect( errorString.includes('429') || errorString.includes('Failed to fetch') ).toBe(true); } }, 60000); }); }); ================ File: services/__tests__/tally.service.proposals.test.ts ================ import { TallyService } from '../tally.service'; import dotenv from 'dotenv'; dotenv.config(); const apiKey = process.env.TALLY_API_KEY; if (!apiKey) { throw new Error('TALLY_API_KEY environment variable is required'); } // Helper function to add delay between API calls const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('TallyService - Proposals', () => { const service = new TallyService({ apiKey }); // Test constants const UNISWAP_ORG_ID = '2206072050458560434'; const UNISWAP_GOVERNOR_ID = 'eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3'; // Add delay between each test beforeEach(async () => { await delay(1000); // 1 second delay between tests }); describe('listProposals', () => { it('should list proposals with basic filters', async () => { const result = await service.listProposals({ filters: { organizationId: UNISWAP_ORG_ID }, page: { limit: 5 } }); // Check response structure expect(result).toHaveProperty('proposals'); expect(result.proposals).toHaveProperty('nodes'); expect(Array.isArray(result.proposals.nodes)).toBe(true); // If there are proposals, check their structure if (result.proposals.nodes.length > 0) { const proposal = result.proposals.nodes[0]; expect(proposal).toHaveProperty('id'); expect(proposal).toHaveProperty('onchainId'); expect(proposal).toHaveProperty('status'); expect(proposal).toHaveProperty('metadata'); expect(proposal).toHaveProperty('voteStats'); expect(proposal).toHaveProperty('governor'); // Check metadata structure expect(proposal.metadata).toHaveProperty('title'); expect(proposal.metadata).toHaveProperty('description'); // Check governor structure expect(proposal.governor).toHaveProperty('id'); expect(proposal.governor).toHaveProperty('name'); expect(proposal.governor.organization).toHaveProperty('name'); expect(proposal.governor.organization).toHaveProperty('slug'); } }); it('should handle pagination correctly', async () => { // First page with smaller limit const firstPage = await service.listProposals({ filters: { organizationId: UNISWAP_ORG_ID }, page: { limit: 2 } }); expect(firstPage.proposals.nodes.length).toBe(2); expect(firstPage.proposals.pageInfo).toHaveProperty('lastCursor'); const firstPageIds = firstPage.proposals.nodes.map(p => p.id); await delay(1000); // Fetch second page const secondPage = await service.listProposals({ filters: { organizationId: UNISWAP_ORG_ID }, page: { limit: 2, afterCursor: firstPage.proposals.pageInfo.lastCursor } }); expect(secondPage.proposals.nodes.length).toBe(2); const secondPageIds = secondPage.proposals.nodes.map(p => p.id); // Verify pages contain different proposals expect(firstPageIds).not.toEqual(secondPageIds); }); it('should apply all filters correctly', async () => { const result = await service.listProposals({ filters: { organizationId: UNISWAP_ORG_ID, governorId: UNISWAP_GOVERNOR_ID, includeArchived: true, isDraft: false }, page: { limit: 3 }, sort: { isDescending: true, sortBy: "id" } }); expect(result.proposals.nodes.length).toBeLessThanOrEqual(3); if (result.proposals.nodes.length > 1) { // Verify sorting const ids = result.proposals.nodes.map(p => BigInt(p.id)); const isSorted = ids.every((id, i) => i === 0 || id <= ids[i - 1]); expect(isSorted).toBe(true); } }); }); describe('getProposal', () => { let proposalId: string; beforeAll(async () => { // Get a real proposal ID from the list const response = await service.listProposals({ filters: { organizationId: UNISWAP_ORG_ID }, page: { limit: 1 } }); if (response.proposals.nodes.length === 0) { throw new Error('No proposals found for testing'); } proposalId = response.proposals.nodes[0].id; console.log('Using proposal ID:', proposalId); }); it('should get proposal by ID', async () => { const result = await service.getProposal({ id: proposalId }); expect(result).toHaveProperty('proposal'); const proposal = result.proposal; // Check basic properties expect(proposal).toHaveProperty('id'); expect(proposal).toHaveProperty('onchainId'); expect(proposal).toHaveProperty('status'); expect(proposal).toHaveProperty('metadata'); expect(proposal).toHaveProperty('voteStats'); expect(proposal).toHaveProperty('governor'); // Check metadata expect(proposal.metadata).toHaveProperty('title'); expect(proposal.metadata).toHaveProperty('description'); expect(proposal.metadata).toHaveProperty('discourseURL'); expect(proposal.metadata).toHaveProperty('snapshotURL'); // Check vote stats expect(Array.isArray(proposal.voteStats)).toBe(true); if (proposal.voteStats.length > 0) { expect(proposal.voteStats[0]).toHaveProperty('votesCount'); expect(proposal.voteStats[0]).toHaveProperty('votersCount'); expect(proposal.voteStats[0]).toHaveProperty('type'); expect(proposal.voteStats[0]).toHaveProperty('percent'); } }); it('should get proposal by onchain ID', async () => { // First get a proposal with an onchain ID const listResponse = await service.listProposals({ filters: { organizationId: UNISWAP_ORG_ID }, page: { limit: 5 } }); const proposalWithOnchainId = listResponse.proposals.nodes.find(p => p.onchainId); if (!proposalWithOnchainId) { console.log('No proposal with onchain ID found, skipping test'); return; } const result = await service.getProposal({ onchainId: proposalWithOnchainId.onchainId, governorId: UNISWAP_GOVERNOR_ID }); expect(result).toHaveProperty('proposal'); expect(result.proposal.onchainId).toBe(proposalWithOnchainId.onchainId); }); it('should include archived proposals', async () => { const result = await service.getProposal({ id: proposalId, includeArchived: true }); expect(result).toHaveProperty('proposal'); expect(result.proposal.id).toBe(proposalId); }); it('should handle errors for invalid proposal ID', async () => { await expect(service.getProposal({ id: 'invalid-id' })).rejects.toThrow(); }); it('should handle errors when using onchainId without governorId', async () => { await expect(service.getProposal({ onchainId: '1' })).rejects.toThrow(); }); it('should format proposal correctly', () => { const mockProposal = { id: '123', onchainId: '1', status: 'active' as const, quorum: '1000000', metadata: { title: 'Test Proposal', description: 'Test Description', discourseURL: 'https://example.com', snapshotURL: 'https://snapshot.org' }, start: { timestamp: '2023-01-01T00:00:00Z' }, end: { timestamp: '2023-01-08T00:00:00Z' }, executableCalls: [{ value: '0', target: '0x123', calldata: '0x', signature: 'test()', type: 'call' }], voteStats: [{ votesCount: '1000000000000000000', votersCount: 100, type: 'for' as const, percent: 75 }], governor: { id: 'gov-1', chainId: 'eip155:1', name: 'Test Governor', token: { decimals: 18 }, organization: { name: 'Test Org', slug: 'test' } }, proposer: { address: '0x123', name: 'Test Proposer', picture: 'https://example.com/avatar.png' } }; const formatted = TallyService.formatProposal(mockProposal); expect(typeof formatted).toBe('string'); expect(formatted).toContain('Test Proposal'); expect(formatted).toContain('Test Description'); expect(formatted).toContain('Test Governor'); }); }); }); ================ File: services/__tests__/tally.service.test.ts ================ import { TallyService } from '../tally.service'; import dotenv from 'dotenv'; dotenv.config(); const apiKey = process.env.TALLY_API_KEY; if (!apiKey) { throw new Error('TALLY_API_KEY environment variable is required'); } describe('TallyService', () => { let tallyService: TallyService; beforeAll(() => { tallyService = new TallyService({ apiKey }); }); describe('getDAO', () => { it('should fetch Uniswap DAO details', async () => { const dao = await tallyService.getDAO('uniswap'); expect(dao).toBeDefined(); expect(dao.name).toBe('Uniswap'); expect(dao.slug).toBe('uniswap'); expect(dao.chainIds).toContain('eip155:1'); expect(dao.governorIds).toBeDefined(); expect(dao.tokenIds).toBeDefined(); expect(dao.metadata).toBeDefined(); if (dao.metadata) { expect(dao.metadata.icon).toBeDefined(); } }, 30000); }); describe('listDelegates', () => { it('should fetch delegates for Uniswap', async () => { const result = await tallyService.listDelegates({ organizationSlug: 'uniswap', limit: 20, hasVotes: true }); // Check the structure of the response expect(result).toHaveProperty('delegates'); expect(result).toHaveProperty('pageInfo'); expect(Array.isArray(result.delegates)).toBe(true); // Check that we got some delegates expect(result.delegates.length).toBeGreaterThan(0); // Check the structure of a delegate const firstDelegate = result.delegates[0]; expect(firstDelegate).toHaveProperty('id'); expect(firstDelegate).toHaveProperty('account'); expect(firstDelegate).toHaveProperty('votesCount'); expect(firstDelegate).toHaveProperty('delegatorsCount'); // Check account properties expect(firstDelegate.account).toHaveProperty('address'); expect(typeof firstDelegate.account.address).toBe('string'); // Check that votesCount is a string (since it's a large number) expect(typeof firstDelegate.votesCount).toBe('string'); // Check that delegatorsCount is a number expect(typeof firstDelegate.delegatorsCount).toBe('number'); // Log the first delegate for manual inspection }, 30000); it('should handle pagination correctly', async () => { // First page const firstPage = await tallyService.listDelegates({ organizationSlug: 'uniswap', limit: 10 }); expect(firstPage.delegates.length).toBeLessThanOrEqual(10); expect(firstPage.pageInfo.lastCursor).toBeTruthy(); // Second page using the cursor only if it's not null if (firstPage.pageInfo.lastCursor) { const secondPage = await tallyService.listDelegates({ organizationSlug: 'uniswap', limit: 10, afterCursor: firstPage.pageInfo.lastCursor }); expect(secondPage.delegates.length).toBeLessThanOrEqual(10); expect(secondPage.delegates[0].id).not.toBe(firstPage.delegates[0].id); } }, 30000); }); }); ================ File: services/delegates/delegates.queries.ts ================ import { gql } from 'graphql-request'; export const LIST_DELEGATES_QUERY = gql` query Delegates($input: DelegatesInput!) { delegates(input: $input) { nodes { ... on Delegate { id account { address bio name picture } votesCount delegatorsCount statement { statementSummary } } } pageInfo { firstCursor lastCursor } } } `; ================ File: services/delegates/delegates.types.ts ================ import { PageInfo } from '../organizations/organizations.types.js'; // Input Types export interface ListDelegatesInput { organizationId?: string; organizationSlug?: string; governorId?: string; limit?: number; afterCursor?: string; beforeCursor?: string; hasVotes?: boolean; hasDelegators?: boolean; isSeekingDelegation?: boolean; sortBy?: 'id' | 'votes'; isDescending?: boolean; } // Response Types export interface Delegate { id: string; account: { address: string; bio?: string; name?: string; picture?: string | null; }; votesCount: string; delegatorsCount: number; statement?: { statementSummary?: string; }; } export interface DelegatesResponse { delegates: { nodes: Delegate[]; pageInfo: PageInfo; }; } export interface ListDelegatesResponse { data: DelegatesResponse; errors?: Array<{ message: string; path: string[]; extensions: { code: number; status: { code: number; message: string; }; }; }>; } ================ File: services/delegates/index.ts ================ export * from './delegates.types.js'; export * from './delegates.queries.js'; export * from './listDelegates.js'; ================ File: services/delegates/listDelegates.ts ================ import { GraphQLClient } from 'graphql-request'; import { LIST_DELEGATES_QUERY } from './delegates.queries.js'; import { DelegatesResponse, Delegate } from './delegates.types.js'; import { PageInfo } from '../organizations/organizations.types.js'; import { getDAO } from '../organizations/getDAO.js'; export async function listDelegates( client: GraphQLClient, input: { organizationId?: string; organizationSlug?: string; limit?: number; afterCursor?: string; beforeCursor?: string; hasVotes?: boolean; hasDelegators?: boolean; isSeekingDelegation?: boolean; } ): Promise<{ delegates: Delegate[]; pageInfo: PageInfo; }> { let organizationId = input.organizationId; // If organizationId is not provided but slug is, get the DAO first if (!organizationId && input.organizationSlug) { const dao = await getDAO(client, input.organizationSlug); organizationId = dao.id; } if (!organizationId) { throw new Error('Either organizationId or organizationSlug must be provided'); } try { const response = await client.request<DelegatesResponse>(LIST_DELEGATES_QUERY, { input: { filters: { organizationId, hasVotes: input.hasVotes, hasDelegators: input.hasDelegators, isSeekingDelegation: input.isSeekingDelegation, }, sort: { isDescending: true, sortBy: 'votes', }, page: { limit: Math.min(input.limit || 20, 50), afterCursor: input.afterCursor, beforeCursor: input.beforeCursor, }, }, }); return { delegates: response.delegates.nodes, pageInfo: response.delegates.pageInfo, }; } catch (error) { throw new Error(`Failed to fetch delegates: ${error instanceof Error ? error.message : 'Unknown error'}`); } } ================ File: services/delegators/delegators.queries.ts ================ import { gql } from 'graphql-request'; export const GET_DELEGATORS_QUERY = gql` query GetDelegators($input: DelegationsInput!) { delegators(input: $input) { nodes { ... on Delegation { chainId delegator { address name picture twitter ens } blockNumber blockTimestamp votes token { id name symbol decimals } } } pageInfo { firstCursor lastCursor } } } `; ================ File: services/delegators/delegators.types.ts ================ import { PageInfo } from "../organizations/organizations.types.js"; // Input Types export interface GetDelegatorsParams { address: string; organizationId?: string; organizationSlug?: string; governorId?: string; limit?: number; afterCursor?: string; beforeCursor?: string; sortBy?: "id" | "votes"; isDescending?: boolean; } // Response Types export interface TokenInfo { id: string; name: string; symbol: string; decimals: number; } export interface Delegation { chainId: string; blockNumber: number; blockTimestamp: string; votes: string; delegator: { address: string; name?: string; picture?: string; twitter?: string; ens?: string; }; token?: { id: string; name: string; symbol: string; decimals: number; }; } export interface DelegationsResponse { delegators: { nodes: Delegation[]; pageInfo: PageInfo; }; } export interface GetDelegatorsResponse { data: DelegationsResponse; errors?: Array<{ message: string; path: string[]; extensions: { code: number; status: { code: number; message: string; }; }; }>; } ================ File: services/delegators/getDelegators.ts ================ import { GraphQLClient } from 'graphql-request'; import { GET_DELEGATORS_QUERY } from './delegators.queries.js'; import { GetDelegatorsParams, DelegationsResponse, Delegation } from './delegators.types.js'; import { PageInfo } from '../organizations/organizations.types.js'; import { getDAO } from '../organizations/getDAO.js'; export async function getDelegators( client: GraphQLClient, params: GetDelegatorsParams ): Promise<{ delegators: Delegation[]; pageInfo: PageInfo; }> { try { let organizationId = params.organizationId; // If organizationId is not provided but slug is, get the organization ID if (!organizationId && params.organizationSlug) { const dao = await getDAO(client, params.organizationSlug); organizationId = dao.id; } if (!organizationId && !params.governorId) { throw new Error('Either organizationId/organizationSlug or governorId must be provided'); } const input = { filters: { address: params.address, ...(organizationId && { organizationId }), ...(params.governorId && { governorId: params.governorId }) }, page: { limit: Math.min(params.limit || 20, 50), ...(params.afterCursor && { afterCursor: params.afterCursor }), ...(params.beforeCursor && { beforeCursor: params.beforeCursor }) }, ...(params.sortBy && { sort: { sortBy: params.sortBy, isDescending: params.isDescending ?? true } }) }; const response = await client.request<DelegationsResponse>( GET_DELEGATORS_QUERY, { input } ); return { delegators: response.delegators.nodes, pageInfo: response.delegators.pageInfo }; } catch (error) { throw new Error(`Failed to fetch delegators: ${error instanceof Error ? error.message : 'Unknown error'}`); } } ================ File: services/delegators/index.ts ================ export * from './delegators.types.js'; export * from './delegators.queries.js'; export * from './getDelegators.js'; ================ File: services/organizations/getDAO.ts ================ import { GraphQLClient } from 'graphql-request'; import { GET_DAO_QUERY } from './organizations.queries.js'; import { Organization } from './organizations.types.js'; export async function getDAO( client: GraphQLClient, slug: string ): Promise<Organization> { try { const input = { slug }; const response = await client.request<{ organization: Organization }>(GET_DAO_QUERY, { input }); if (!response.organization) { throw new Error(`DAO not found: ${slug}`); } // Map the response to match our Organization interface const dao: Organization = { ...response.organization, metadata: { ...response.organization.metadata, websiteUrl: response.organization.metadata?.socials?.website || undefined, discord: response.organization.metadata?.socials?.discord || undefined, twitter: response.organization.metadata?.socials?.twitter || undefined, } }; return dao; } catch (error) { throw new Error(`Failed to fetch DAO: ${error instanceof Error ? error.message : 'Unknown error'}`); } } ================ File: services/organizations/index.ts ================ export * from './organizations.types.js'; export * from './organizations.queries.js'; export * from './listDAOs.js'; export * from './getDAO.js'; ================ File: services/organizations/listDAOs.ts ================ import { GraphQLClient } from 'graphql-request'; import { LIST_DAOS_QUERY } from './organizations.queries.js'; import { ListDAOsParams, OrganizationsInput, OrganizationsResponse } from './organizations.types.js'; export async function listDAOs( client: GraphQLClient, params: ListDAOsParams = {} ): Promise<OrganizationsResponse> { const input: OrganizationsInput = { sort: { sortBy: params.sortBy || "popular", isDescending: true }, page: { limit: Math.min(params.limit || 20, 50) } }; if (params.afterCursor) { input.page!.afterCursor = params.afterCursor; } if (params.beforeCursor) { input.page!.beforeCursor = params.beforeCursor; } try { const response = await client.request<OrganizationsResponse>(LIST_DAOS_QUERY, { input }); return response; } catch (error) { throw new Error(`Failed to fetch DAOs: ${error instanceof Error ? error.message : 'Unknown error'}`); } } ================ File: services/organizations/organizations.queries.ts ================ import { gql } from 'graphql-request'; export const LIST_DAOS_QUERY = gql` query Organizations($input: OrganizationsInput!) { organizations(input: $input) { nodes { ... on Organization { id name slug chainIds proposalsCount hasActiveProposals tokenOwnersCount delegatesCount } } pageInfo { firstCursor lastCursor } } } `; export const GET_DAO_QUERY = gql` query OrganizationBySlug($input: OrganizationInput!) { organization(input: $input) { id name slug chainIds governorIds tokenIds hasActiveProposals proposalsCount delegatesCount tokenOwnersCount metadata { description icon socials { website discord telegram twitter discourse others { label value } } karmaName } features { name enabled } } } `; ================ File: services/organizations/organizations.types.ts ================ // Basic Types export type OrganizationsSortBy = "id" | "name" | "explore" | "popular"; // Input Types export interface OrganizationsSortInput { isDescending: boolean; sortBy: OrganizationsSortBy; } export interface PageInput { afterCursor?: string; beforeCursor?: string; limit?: number; } export interface OrganizationsFiltersInput { hasLogo?: boolean; chainId?: string; isMember?: boolean; address?: string; slug?: string; name?: string; } export interface OrganizationsInput { filters?: OrganizationsFiltersInput; page?: PageInput; sort?: OrganizationsSortInput; search?: string; } export interface ListDAOsParams { limit?: number; afterCursor?: string; beforeCursor?: string; sortBy?: OrganizationsSortBy; } // Response Types export interface Organization { id: string; slug: string; name: string; chainIds: string[]; tokenIds?: string[]; governorIds?: string[]; metadata?: { description?: string; icon?: string; websiteUrl?: string; twitter?: string; discord?: string; github?: string; termsOfService?: string; governanceUrl?: string; socials?: { website?: string; discord?: string; telegram?: string; twitter?: string; discourse?: string; others?: Array<{ label: string; value: string; }>; }; karmaName?: string; }; features?: Array<{ name: string; enabled: boolean; }>; hasActiveProposals: boolean; proposalsCount: number; delegatesCount: number; tokenOwnersCount: number; stats?: { proposalsCount: number; activeProposalsCount: number; tokenHoldersCount: number; votersCount: number; delegatesCount: number; delegatedVotesCount: string; }; } export interface PageInfo { firstCursor: string | null; lastCursor: string | null; } export interface OrganizationsResponse { organizations: { nodes: Organization[]; pageInfo: PageInfo; }; } export interface GetDAOResponse { organizations: { nodes: Organization[]; }; } export interface ListDAOsResponse { data: OrganizationsResponse; errors?: Array<{ message: string; path: string[]; extensions: { code: number; status: { code: number; message: string; }; }; }>; } export interface GetDAOBySlugResponse { data: GetDAOResponse; errors?: Array<{ message: string; path: string[]; extensions: { code: number; status: { code: number; message: string; }; }; }>; } ================ File: services/proposals/getProposal.ts ================ import { GraphQLClient } from 'graphql-request'; import { GET_PROPOSAL_QUERY } from './proposals.queries.js'; import type { ProposalInput, ProposalDetailsResponse } from './getProposal.types.js'; import { getDAO } from '../organizations/getDAO.js'; export async function getProposal( client: GraphQLClient, input: ProposalInput & { organizationSlug?: string } ): Promise<ProposalDetailsResponse> { try { let apiInput: ProposalInput = { ...input }; delete (apiInput as any).organizationSlug; // Remove organizationSlug before API call // If organizationSlug is provided but no organizationId, get the DAO first if (input.organizationSlug && !apiInput.governorId) { const dao = await getDAO(client, input.organizationSlug); // Use the first governor ID from the DAO if (dao.governorIds && dao.governorIds.length > 0) { apiInput.governorId = dao.governorIds[0]; } } // Ensure ID is not wrapped in quotes if it's numeric if (apiInput.id && typeof apiInput.id === 'string' && /^\d+$/.test(apiInput.id)) { apiInput = { ...apiInput, id: apiInput.id.replace(/['"]/g, '') // Remove any quotes }; } const response = await client.request<ProposalDetailsResponse>(GET_PROPOSAL_QUERY, { input: apiInput }); return response; } catch (error) { throw new Error(`Failed to fetch proposal: ${error instanceof Error ? error.message : 'Unknown error'}`); } } ================ File: services/proposals/getProposal.types.ts ================ import { AccountID, IntID } from './listProposals.types.js'; // Input Types export interface ProposalInput { id?: IntID; onchainId?: string; governorId?: AccountID; includeArchived?: boolean; isLatest?: boolean; } export interface GetProposalVariables { input: ProposalInput; } // Response Types export interface ProposalDetailsMetadata { title: string; description: string; discourseURL: string; snapshotURL: string; } export interface ProposalDetailsVoteStats { votesCount: string; votersCount: number; type: "for" | "against" | "abstain" | "pendingfor" | "pendingagainst" | "pendingabstain"; percent: number; } export interface ProposalDetailsGovernor { id: AccountID; chainId: string; name: string; token: { decimals: number; }; organization: { name: string; slug: string; }; } export interface ProposalDetailsProposer { address: AccountID; name: string; picture?: string; } export interface TimeBlock { timestamp: string; } export interface ExecutableCall { value: string; target: string; calldata: string; signature: string; type: string; } export interface ProposalDetails { id: IntID; onchainId: string; metadata: ProposalDetailsMetadata; status: "active" | "canceled" | "defeated" | "executed" | "expired" | "pending" | "queued" | "succeeded"; quorum: string; start: TimeBlock; end: TimeBlock; executableCalls: ExecutableCall[]; voteStats: ProposalDetailsVoteStats[]; governor: ProposalDetailsGovernor; proposer: ProposalDetailsProposer; } export interface ProposalDetailsResponse { proposal: ProposalDetails; } export interface GetProposalResponse { data: ProposalDetailsResponse; errors?: Array<{ message: string; path: string[]; extensions: { code: number; status: { code: number; message: string; }; }; }>; } ================ File: services/proposals/index.ts ================ export * from './listProposals.types.js'; export * from './getProposal.types.js'; export * from './proposals.queries.js'; export * from './listProposals.js'; export * from './getProposal.js'; ================ File: services/proposals/listProposals.ts ================ import { GraphQLClient } from 'graphql-request'; import { LIST_PROPOSALS_QUERY } from './proposals.queries.js'; import { getDAO } from '../organizations/getDAO.js'; import type { ProposalsInput, ProposalsResponse } from './listProposals.types.js'; export async function listProposals( client: GraphQLClient, input: ProposalsInput & { organizationSlug?: string } ): Promise<ProposalsResponse> { try { let apiInput: ProposalsInput = { ...input }; delete (apiInput as any).organizationSlug; // Remove organizationSlug before API call // If organizationSlug is provided but no organizationId, get the DAO first if (!apiInput.filters?.organizationId && input.organizationSlug) { const dao = await getDAO(client, input.organizationSlug); apiInput = { ...apiInput, filters: { ...apiInput.filters, organizationId: dao.id } }; } const response = await client.request<ProposalsResponse>(LIST_PROPOSALS_QUERY, { input: apiInput }); return response; } catch (error) { throw new Error(`Failed to fetch proposals: ${error instanceof Error ? error.message : 'Unknown error'}`); } } ================ File: services/proposals/listProposals.types.ts ================ // Basic Types export type AccountID = string; export type IntID = string; // Input Types export interface ProposalsInput { filters?: { governorId?: AccountID; organizationId?: IntID; includeArchived?: boolean; isDraft?: boolean; }; page?: { afterCursor?: string; beforeCursor?: string; limit?: number; // max 50 }; sort?: { isDescending: boolean; sortBy: "id"; // default sorts by date }; } export interface ListProposalsVariables { input: ProposalsInput; } // Response Types export interface ProposalVoteStats { votesCount: string; percent: number; type: "for" | "against" | "abstain" | "pendingfor" | "pendingagainst" | "pendingabstain"; votersCount: number; } export interface ProposalMetadata { description: string; title: string; discourseURL: string; snapshotURL: string; } export interface TimeBlock { timestamp: string; } export interface ExecutableCall { value: string; target: string; calldata: string; signature: string; type: string; } export interface ProposalGovernor { id: AccountID; chainId: string; name: string; token: { decimals: number; }; organization: { name: string; slug: string; }; } export interface ProposalProposer { address: AccountID; name: string; picture?: string; } export interface Proposal { id: IntID; onchainId: string; status: "active" | "canceled" | "defeated" | "executed" | "expired" | "pending" | "queued" | "succeeded"; createdAt: string; quorum: string; metadata: ProposalMetadata; start: TimeBlock; end: TimeBlock; executableCalls: ExecutableCall[]; voteStats: ProposalVoteStats[]; governor: ProposalGovernor; proposer: ProposalProposer; } export interface ProposalsResponse { proposals: { nodes: Proposal[]; pageInfo: { firstCursor: string; lastCursor: string; }; }; } export interface ListProposalsResponse { data: ProposalsResponse; errors?: Array<{ message: string; path: string[]; extensions: { code: number; status: { code: number; message: string; }; }; }>; } ================ File: services/proposals/proposals.queries.ts ================ import { gql } from 'graphql-request'; export const LIST_PROPOSALS_QUERY = gql` query GovernanceProposals($input: ProposalsInput!) { proposals(input: $input) { nodes { ... on Proposal { id onchainId status createdAt quorum metadata { description title discourseURL snapshotURL } start { ... on Block { timestamp } ... on BlocklessTimestamp { timestamp } } end { ... on Block { timestamp } ... on BlocklessTimestamp { timestamp } } executableCalls { value target calldata signature type } voteStats { votesCount percent type votersCount } governor { id chainId name token { decimals } organization { name slug } } proposer { address name picture } } } pageInfo { firstCursor lastCursor } } } `; export const GET_PROPOSAL_QUERY = gql` query ProposalDetails($input: ProposalInput!) { proposal(input: $input) { id onchainId metadata { title description discourseURL snapshotURL } status quorum start { ... on Block { timestamp } ... on BlocklessTimestamp { timestamp } } end { ... on Block { timestamp } ... on BlocklessTimestamp { timestamp } } executableCalls { value target calldata signature type } voteStats { votesCount votersCount type percent } governor { id chainId name token { decimals } organization { name slug } } proposer { address name picture } } } `; ================ File: services/index.ts ================ export * from './organizations/index.js'; export * from './delegates/index.js'; export * from './delegators/index.js'; export * from './proposals/index.js'; export interface TallyServiceConfig { apiKey: string; baseUrl?: string; } ================ File: services/tally.service.ts ================ import { GraphQLClient } from 'graphql-request'; import { listDAOs } from './organizations/listDAOs.js'; import { getDAO } from './organizations/getDAO.js'; import { listDelegates } from './delegates/listDelegates.js'; import { getDelegators } from './delegators/getDelegators.js'; import { listProposals } from './proposals/listProposals.js'; import { getProposal } from './proposals/getProposal.js'; import type { Organization, OrganizationsResponse, ListDAOsParams, } from './organizations/organizations.types.js'; import type { Delegate } from './delegates/delegates.types.js'; import type { Delegation, GetDelegatorsParams, TokenInfo } from './delegators/delegators.types.js'; import type { PageInfo } from './organizations/organizations.types.js'; import type { ProposalsInput, ProposalsResponse, ProposalInput, ProposalDetailsResponse, } from './proposals/index.js'; export interface TallyServiceConfig { apiKey: string; baseUrl?: string; } export interface OpenAIFunctionDefinition { name: string; description: string; parameters: { type: string; properties?: Record<string, unknown>; required?: string[]; oneOf?: Array<{ required: string[]; properties: Record<string, unknown>; }>; }; } export const OPENAI_FUNCTION_DEFINITIONS: OpenAIFunctionDefinition[] = [ { name: "list-daos", description: "List DAOs on Tally sorted by specified criteria", parameters: { type: "object", properties: { limit: { type: "number", description: "Maximum number of DAOs to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination", }, sortBy: { type: "string", enum: ["id", "name", "explore", "popular"], description: "How to sort the DAOs (default: popular). 'explore' prioritizes DAOs with live proposals", }, }, }, }, { name: "get-dao", description: "Get detailed information about a specific DAO", parameters: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "The DAO's slug (e.g., 'uniswap' or 'aave')", }, }, }, }, { name: "list-delegates", description: "List delegates for a specific organization with their metadata", parameters: { type: "object", required: ["organizationIdOrSlug"], properties: { organizationIdOrSlug: { type: "string", description: "The organization's ID or slug (e.g., 'arbitrum' or 'eip155:1:123')", }, limit: { type: "number", description: "Maximum number of delegates to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination", }, hasVotes: { type: "boolean", description: "Filter for delegates with votes", }, hasDelegators: { type: "boolean", description: "Filter for delegates with delegators", }, isSeekingDelegation: { type: "boolean", description: "Filter for delegates seeking delegation", }, }, }, }, { name: "get-delegators", description: "Get list of delegators for a specific address", parameters: { type: "object", required: ["address"], properties: { address: { type: "string", description: "The Ethereum address to get delegators for (0x format)", }, organizationId: { type: "string", description: "Filter by specific organization ID", }, governorId: { type: "string", description: "Filter by specific governor ID", }, limit: { type: "number", description: "Maximum number of delegators to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination", }, beforeCursor: { type: "string", description: "Cursor for previous page pagination", }, sortBy: { type: "string", enum: ["id", "votes"], description: "How to sort the delegators (default: id)", }, isDescending: { type: "boolean", description: "Sort in descending order (default: true)", }, }, }, }, { name: "list-proposals", description: "List proposals for a specific organization or governor", parameters: { type: "object", properties: { organizationId: { type: "string", description: "Filter by organization ID (large integer as string)", }, organizationSlug: { type: "string", description: "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId", }, governorId: { type: "string", description: "Filter by governor ID", }, includeArchived: { type: "boolean", description: "Include archived proposals", }, isDraft: { type: "boolean", description: "Filter for draft proposals", }, limit: { type: "number", description: "Maximum number of proposals to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination (string ID)", }, beforeCursor: { type: "string", description: "Cursor for previous page pagination (string ID)", }, isDescending: { type: "boolean", description: "Sort in descending order (default: true)", }, }, }, }, { name: "get-proposal", 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).", parameters: { type: "object", oneOf: [ { required: ["id"], properties: { id: { type: "string", description: "The proposal's Tally ID (globally unique across all governors)", }, includeArchived: { type: "boolean", description: "Include archived proposals", }, isLatest: { type: "boolean", description: "Get the latest version of the proposal", }, }, }, { required: ["onchainId", "governorId"], properties: { onchainId: { type: "string", description: "The proposal's onchain ID (only unique within a governor)", }, governorId: { type: "string", description: "The governor's ID (required when using onchainId)", }, includeArchived: { type: "boolean", description: "Include archived proposals", }, isLatest: { type: "boolean", description: "Get the latest version of the proposal", }, }, }, ], }, }, ]; export class TallyService { private client: GraphQLClient; private static readonly DEFAULT_BASE_URL = 'https://api.tally.xyz/query'; constructor(private config: TallyServiceConfig) { this.client = new GraphQLClient(config.baseUrl || TallyService.DEFAULT_BASE_URL, { headers: { 'Api-Key': config.apiKey, }, }); } static getOpenAIFunctionDefinitions(): OpenAIFunctionDefinition[] { return OPENAI_FUNCTION_DEFINITIONS; } /** * Format a vote amount considering token decimals * @param {string} votes - The raw vote amount * @param {TokenInfo} token - Optional token info containing decimals and symbol * @returns {string} Formatted vote amount with optional symbol */ private static formatVotes(votes: string, token?: TokenInfo): string { const val = BigInt(votes); const decimals = token?.decimals ?? 18; const denominator = BigInt(10 ** decimals); const formatted = (Number(val) / Number(denominator)).toLocaleString(); return `${formatted}${token?.symbol ? ` ${token.symbol}` : ''}`; } async listDAOs(params: ListDAOsParams = {}): Promise<OrganizationsResponse> { return listDAOs(this.client, params); } async getDAO(slug: string): Promise<Organization> { return getDAO(this.client, slug); } public async listDelegates(input: { organizationId?: string; organizationSlug?: string; limit?: number; afterCursor?: string; beforeCursor?: string; hasVotes?: boolean; hasDelegators?: boolean; isSeekingDelegation?: boolean; }): Promise<{ delegates: Delegate[]; pageInfo: PageInfo; }> { return listDelegates(this.client, input); } async getDelegators(params: GetDelegatorsParams): Promise<{ delegators: Delegation[]; pageInfo: PageInfo; }> { return getDelegators(this.client, params); } async listProposals(input: ProposalsInput & { organizationSlug?: string }): Promise<ProposalsResponse> { return listProposals(this.client, input); } async getProposal(input: ProposalInput & { organizationSlug?: string }): Promise<ProposalDetailsResponse> { return getProposal(this.client, input); } // Keep the formatting utility functions in the service static formatDAOList(daos: Organization[]): string { return `Found ${daos.length} DAOs:\n\n` + daos.map(dao => `${dao.name} (${dao.slug})\n` + `Token Holders: ${dao.tokenOwnersCount}\n` + `Delegates: ${dao.delegatesCount}\n` + `Proposals: ${dao.proposalsCount}\n` + `Active Proposals: ${dao.hasActiveProposals ? 'Yes' : 'No'}\n` + `Description: ${dao.metadata?.description || 'No description available'}\n` + `Website: ${dao.metadata?.websiteUrl || 'N/A'}\n` + `Twitter: ${dao.metadata?.twitter || 'N/A'}\n` + `Discord: ${dao.metadata?.discord || 'N/A'}\n` + `GitHub: ${dao.metadata?.github || 'N/A'}\n` + `Governance: ${dao.metadata?.governanceUrl || 'N/A'}\n` + '---' ).join('\n\n'); } static formatDAO(dao: Organization): string { return `${dao.name} (${dao.slug})\n` + `Token Holders: ${dao.tokenOwnersCount}\n` + `Delegates: ${dao.delegatesCount}\n` + `Proposals: ${dao.proposalsCount}\n` + `Active Proposals: ${dao.hasActiveProposals ? 'Yes' : 'No'}\n` + `Description: ${dao.metadata?.description || 'No description available'}\n` + `Website: ${dao.metadata?.websiteUrl || 'N/A'}\n` + `Twitter: ${dao.metadata?.twitter || 'N/A'}\n` + `Discord: ${dao.metadata?.discord || 'N/A'}\n` + `GitHub: ${dao.metadata?.github || 'N/A'}\n` + `Governance: ${dao.metadata?.governanceUrl || 'N/A'}\n` + `Chain IDs: ${dao.chainIds.join(', ')}\n` + `Token IDs: ${dao.tokenIds?.join(', ') || 'N/A'}\n` + `Governor IDs: ${dao.governorIds?.join(', ') || 'N/A'}`; } static formatDelegatesList(delegates: Delegate[]): string { return `Found ${delegates.length} delegates:\n\n` + delegates.map(delegate => `${delegate.account.name || delegate.account.address}\n` + `Address: ${delegate.account.address}\n` + `Votes: ${delegate.votesCount}\n` + `Delegators: ${delegate.delegatorsCount}\n` + `Bio: ${delegate.account.bio || 'No bio available'}\n` + `Statement: ${delegate.statement?.statementSummary || 'No statement available'}\n` + '---' ).join('\n\n'); } static formatDelegatorsList(delegators: Delegation[]): string { return `Found ${delegators.length} delegators:\n\n` + delegators.map(delegation => `${delegation.delegator.name || delegation.delegator.ens || delegation.delegator.address}\n` + `Address: ${delegation.delegator.address}\n` + `Votes: ${TallyService.formatVotes(delegation.votes, delegation.token)}\n` + `Delegated at: Block ${delegation.blockNumber} (${new Date(delegation.blockTimestamp).toLocaleString()})\n` + `${delegation.token ? `Token: ${delegation.token.symbol} (${delegation.token.name})\n` : ''}` + '---' ).join('\n\n'); } static formatProposalsList(proposals: ProposalsResponse['proposals']['nodes']): string { return `Found ${proposals.length} proposals:\n\n` + proposals.map(proposal => `${proposal.metadata.title}\n` + `Tally ID: ${proposal.id}\n` + `Onchain ID: ${proposal.onchainId}\n` + `Status: ${proposal.status}\n` + `Created: ${new Date(proposal.createdAt).toLocaleString()}\n` + `Quorum: ${proposal.quorum}\n` + `Organization: ${proposal.governor.organization.name} (${proposal.governor.organization.slug})\n` + `Governor: ${proposal.governor.name}\n` + `Vote Stats:\n${proposal.voteStats.map(stat => ` ${stat.type}: ${stat.percent.toFixed(2)}% (${stat.votesCount} votes from ${stat.votersCount} voters)` ).join('\n')}\n` + `Description: ${proposal.metadata.description.slice(0, 200)}${proposal.metadata.description.length > 200 ? '...' : ''}\n` + '---' ).join('\n\n'); } static formatProposal(proposal: ProposalDetailsResponse['proposal']): string { return `${proposal.metadata.title}\n` + `Tally ID: ${proposal.id}\n` + `Onchain ID: ${proposal.onchainId}\n` + `Status: ${proposal.status}\n` + `Quorum: ${proposal.quorum}\n` + `Organization: ${proposal.governor.organization.name} (${proposal.governor.organization.slug})\n` + `Governor: ${proposal.governor.name}\n` + `Proposer: ${proposal.proposer.name || proposal.proposer.address}\n` + `Vote Stats:\n${proposal.voteStats.map(stat => ` ${stat.type}: ${stat.percent.toFixed(2)}% (${stat.votesCount} votes from ${stat.votersCount} voters)` ).join('\n')}\n` + `Description:\n${proposal.metadata.description}\n` + `Links:\n` + ` Discourse: ${proposal.metadata.discourseURL || 'N/A'}\n` + ` Snapshot: ${proposal.metadata.snapshotURL || 'N/A'}`; } } ================ File: index.ts ================ #!/usr/bin/env node import * as dotenv from 'dotenv'; import { TallyServer } from './server.js'; // Load environment variables dotenv.config(); const apiKey = process.env.TALLY_API_KEY; if (!apiKey) { console.error("Error: TALLY_API_KEY environment variable is required"); process.exit(1); } // Create and start the server const server = new TallyServer(apiKey); server.start().catch((error) => { console.error("Fatal error:", error); process.exit(1); }); ================ File: server.ts ================ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, type Tool, type TextContent } from "@modelcontextprotocol/sdk/types.js"; import { TallyService } from './services/tally.service.js'; import type { OrganizationsSortBy } from './services/organizations/organizations.types.js'; export class TallyServer { private server: Server; private service: TallyService; constructor(apiKey: string) { // Initialize service this.service = new TallyService({ apiKey }); // Create server instance this.server = new Server( { name: "tally-api", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); this.setupHandlers(); } private setupHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools: Tool[] = [ { name: "list-daos", description: "List DAOs on Tally sorted by specified criteria", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum number of DAOs to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination", }, sortBy: { type: "string", enum: ["id", "name", "explore", "popular"], description: "How to sort the DAOs (default: popular). 'explore' prioritizes DAOs with live proposals", }, }, }, }, { name: "get-dao", description: "Get detailed information about a specific DAO", inputSchema: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "The DAO's slug (e.g., 'uniswap' or 'aave')", }, }, }, }, { name: "list-delegates", description: "List delegates for a specific organization with their metadata", inputSchema: { type: "object", required: ["organizationIdOrSlug"], properties: { organizationIdOrSlug: { type: "string", description: "The organization's ID or slug (e.g., 'arbitrum' or 'eip155:1:123')", }, limit: { type: "number", description: "Maximum number of delegates to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination", }, hasVotes: { type: "boolean", description: "Filter for delegates with votes", }, hasDelegators: { type: "boolean", description: "Filter for delegates with delegators", }, isSeekingDelegation: { type: "boolean", description: "Filter for delegates seeking delegation", }, }, }, }, { name: "get-delegators", description: "Get list of delegators for a specific address", inputSchema: { type: "object", required: ["address"], properties: { address: { type: "string", description: "The Ethereum address to get delegators for (0x format)", }, organizationId: { type: "string", description: "Filter by specific organization ID", }, organizationSlug: { type: "string", description: "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId", }, governorId: { type: "string", description: "Filter by specific governor ID", }, limit: { type: "number", description: "Maximum number of delegators to return (default: 20, max: 50)", }, afterCursor: { type: "string", description: "Cursor for pagination", }, beforeCursor: { type: "string", description: "Cursor for previous page pagination", }, sortBy: { type: "string", enum: ["id", "votes"], description: "How to sort the delegators (default: id)", }, isDescending: { type: "boolean", description: "Sort in descending order (default: true)", }, }, }, }, { name: "list-proposals", description: "List proposals for a specific organization or governor", inputSchema: { type: "object", properties: { organizationId: { type: "string", description: "Filter by organization ID (large integer as string)" }, organizationSlug: { type: "string", description: "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId" }, governorId: { type: "string", description: "Filter by governor ID" }, includeArchived: { type: "boolean", description: "Include archived proposals" }, isDraft: { type: "boolean", description: "Filter for draft proposals" }, limit: { type: "number", description: "Maximum number of proposals to return (default: 20, max: 50)" }, afterCursor: { type: "string", description: "Cursor for pagination (string ID)" }, beforeCursor: { type: "string", description: "Cursor for previous page pagination (string ID)" }, isDescending: { type: "boolean", description: "Sort in descending order (default: true)" }, }, }, }, { name: "get-proposal", 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).", inputSchema: { type: "object", oneOf: [ { required: ["id"], properties: { id: { type: "string", description: "The proposal's Tally ID (globally unique across all governors)" }, includeArchived: { type: "boolean", description: "Include archived proposals" }, isLatest: { type: "boolean", description: "Get the latest version of the proposal" } } }, { required: ["onchainId", "governorId"], properties: { onchainId: { type: "string", description: "The proposal's onchain ID (only unique within a governor)" }, governorId: { type: "string", description: "The governor's ID (required when using onchainId)" }, includeArchived: { type: "boolean", description: "Include archived proposals" }, isLatest: { type: "boolean", description: "Get the latest version of the proposal" } } } ] }, }, ]; return { tools }; }); // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args = {} } = request.params; if (name === "list-daos") { try { const data = await this.service.listDAOs({ limit: typeof args.limit === 'number' ? args.limit : undefined, afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor : undefined, sortBy: typeof args.sortBy === 'string' ? args.sortBy as OrganizationsSortBy : undefined, }); const content: TextContent[] = [ { type: "text", text: TallyService.formatDAOList(data.organizations.nodes) } ]; return { content }; } catch (error) { throw new Error(`Error fetching DAOs: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (name === "get-dao") { try { if (typeof args.slug !== 'string') { throw new Error('slug must be a string'); } const data = await this.service.getDAO(args.slug); const content: TextContent[] = [ { type: "text", text: TallyService.formatDAO(data) } ]; return { content }; } catch (error) { throw new Error(`Error fetching DAO: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (name === "list-delegates") { try { if (typeof args.organizationIdOrSlug !== 'string') { throw new Error('organizationIdOrSlug must be a string'); } // Determine if the input is an ID or slug // If it contains 'eip155' or is numeric, treat as ID, otherwise as slug const isId = args.organizationIdOrSlug.includes('eip155') || /^\d+$/.test(args.organizationIdOrSlug); const data = await this.service.listDelegates({ ...(isId ? { organizationId: args.organizationIdOrSlug } : { organizationSlug: args.organizationIdOrSlug }), limit: typeof args.limit === 'number' ? args.limit : undefined, afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor : undefined, hasVotes: typeof args.hasVotes === 'boolean' ? args.hasVotes : undefined, hasDelegators: typeof args.hasDelegators === 'boolean' ? args.hasDelegators : undefined, isSeekingDelegation: typeof args.isSeekingDelegation === 'boolean' ? args.isSeekingDelegation : undefined, }); const content: TextContent[] = [ { type: "text", text: TallyService.formatDelegatesList(data.delegates) } ]; return { content }; } catch (error) { throw new Error(`Error fetching delegates: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (name === "get-delegators") { try { if (typeof args.address !== 'string') { throw new Error('address must be a string'); } const data = await this.service.getDelegators({ address: args.address, organizationId: typeof args.organizationId === 'string' ? args.organizationId : undefined, organizationSlug: typeof args.organizationSlug === 'string' ? args.organizationSlug : undefined, governorId: typeof args.governorId === 'string' ? args.governorId : undefined, limit: typeof args.limit === 'number' ? args.limit : undefined, afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor : undefined, beforeCursor: typeof args.beforeCursor === 'string' ? args.beforeCursor : undefined, sortBy: typeof args.sortBy === 'string' ? args.sortBy as 'id' | 'votes' : undefined, isDescending: typeof args.isDescending === 'boolean' ? args.isDescending : undefined, }); const content: TextContent[] = [ { type: "text", text: TallyService.formatDelegatorsList(data.delegators) } ]; return { content }; } catch (error) { throw new Error(`Error fetching delegators: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (name === "list-proposals") { try { const data = await this.service.listProposals({ filters: { organizationId: typeof args.organizationId === 'string' ? args.organizationId.toString() : undefined, governorId: typeof args.governorId === 'string' ? args.governorId : undefined, includeArchived: typeof args.includeArchived === 'boolean' ? args.includeArchived : undefined, isDraft: typeof args.isDraft === 'boolean' ? args.isDraft : undefined, }, organizationSlug: typeof args.organizationSlug === 'string' ? args.organizationSlug : undefined, page: { limit: typeof args.limit === 'number' ? args.limit : undefined, afterCursor: typeof args.afterCursor === 'string' ? args.afterCursor.toString() : undefined, beforeCursor: typeof args.beforeCursor === 'string' ? args.beforeCursor.toString() : undefined, }, sort: typeof args.isDescending === 'boolean' ? { isDescending: args.isDescending, sortBy: "id" } : undefined }); const content: TextContent[] = [ { type: "text", text: TallyService.formatProposalsList(data.proposals.nodes) } ]; return { content }; } catch (error) { throw new Error(`Error fetching proposals: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (name === "get-proposal") { try { // If we have just an ID, we can use it directly if (typeof args.id === 'string') { const data = await this.service.getProposal({ id: args.id, includeArchived: typeof args.includeArchived === 'boolean' ? args.includeArchived : undefined, isLatest: typeof args.isLatest === 'boolean' ? args.isLatest : undefined, }); return { content: [{ type: "text", text: TallyService.formatProposal(data.proposal) }] }; } // If we have onchainId and governorId, use them together if (typeof args.onchainId === 'string' && typeof args.governorId === 'string') { const data = await this.service.getProposal({ onchainId: args.onchainId, governorId: args.governorId, includeArchived: typeof args.includeArchived === 'boolean' ? args.includeArchived : undefined, isLatest: typeof args.isLatest === 'boolean' ? args.isLatest : undefined, }); return { content: [{ type: "text", text: TallyService.formatProposal(data.proposal) }] }; } throw new Error('Must provide either id or both onchainId and governorId'); } catch (error) { throw new Error(`Error fetching proposal: ${error instanceof Error ? error.message : 'Unknown error'}`); } } throw new Error(`Unknown tool: ${name}`); }); } async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Tally MCP Server running on stdio"); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/crazyrabbitLTC/mpc-tally-api-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server