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