#
tokens: 22377/50000 1/103 files (page 4/4)
lines: off (toggle) GitHub
raw markdown copy
This is page 4 of 4. Use http://codebase.md/crazyrabbitltc/mpc-tally-api-server?lines=false&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:
--------------------------------------------------------------------------------

```
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");
  }
}

```
Page 4/4FirstPrevNextLast