This is page 2 of 5. Use http://codebase.md/crazyrabbitltc/mpc-tally-api-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .gitignore
├── bun.lockb
├── docs
│ ├── issues
│ │ └── address-votes-api-schema.md
│ └── rate-limiting-notes.md
├── jest.config.js
├── LICENSE
├── list of tools
├── LLM-API-GUIDE-2 copy.txt
├── LLM-API-GUIDE-2.txt
├── LLM-API-GUIDE.txt
├── package-lock.json
├── package.json
├── proposals_response.json
├── README.md
├── repomix-output.txt
├── src
│ ├── index.ts
│ ├── repomix-output.txt
│ ├── server.ts
│ ├── services
│ │ ├── __tests__
│ │ │ ├── client
│ │ │ │ ├── setup.ts
│ │ │ │ ├── tallyServer.test.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── mcpClientTests
│ │ │ │ └── mcpServer.test.ts
│ │ │ ├── tally.service.address-created-proposals.test.ts
│ │ │ ├── tally.service.address-dao-proposals.test.ts
│ │ │ ├── tally.service.address-daos.test.ts
│ │ │ ├── tally.service.address-governances.test.ts
│ │ │ ├── tally.service.address-metadata.test.ts
│ │ │ ├── tally.service.address-received-delegations.test.ts
│ │ │ ├── tally.service.address-safes.test.ts
│ │ │ ├── tally.service.address-votes.test.ts
│ │ │ ├── tally.service.addresses.test.ts
│ │ │ ├── tally.service.dao.test.ts
│ │ │ ├── tally.service.daos.test.ts
│ │ │ ├── tally.service.delegate-statement.test.ts
│ │ │ ├── tally.service.delegates.test.ts
│ │ │ ├── tally.service.delegators.test.ts
│ │ │ ├── tally.service.errors.test.ts
│ │ │ ├── tally.service.governance-proposals-stats.test.ts
│ │ │ ├── tally.service.list-delegates.test.ts
│ │ │ ├── tally.service.proposal-security-analysis.test.ts
│ │ │ ├── tally.service.proposal-timeline.test.ts
│ │ │ ├── tally.service.proposal-voters.test.ts
│ │ │ ├── tally.service.proposal-votes-cast-list.test.ts
│ │ │ ├── tally.service.proposal-votes-cast.test.ts
│ │ │ ├── tally.service.proposals.test.ts
│ │ │ ├── tally.service.test.ts
│ │ │ └── tsconfig.json
│ │ ├── addresses
│ │ │ ├── addresses.queries.ts
│ │ │ ├── addresses.types.ts
│ │ │ ├── getAddressCreatedProposals.ts
│ │ │ ├── getAddressDAOProposals.ts
│ │ │ ├── getAddressGovernances.ts
│ │ │ ├── getAddressMetadata.ts
│ │ │ ├── getAddressProposals.ts
│ │ │ ├── getAddressReceivedDelegations.ts
│ │ │ ├── getAddressSafes.ts
│ │ │ ├── getAddressVotes.ts
│ │ │ └── index.ts
│ │ ├── delegates
│ │ │ ├── delegates.queries.ts
│ │ │ ├── delegates.types.ts
│ │ │ ├── getDelegateStatement.ts
│ │ │ ├── index.ts
│ │ │ └── listDelegates.ts
│ │ ├── delegators
│ │ │ ├── delegators.queries.ts
│ │ │ ├── delegators.types.ts
│ │ │ ├── getDelegators.ts
│ │ │ └── index.ts
│ │ ├── errors
│ │ │ └── apiErrors.ts
│ │ ├── index.ts
│ │ ├── organizations
│ │ │ ├── __tests__
│ │ │ │ ├── organizations.queries.test.ts
│ │ │ │ ├── organizations.service.test.ts
│ │ │ │ └── tally.service.test.ts
│ │ │ ├── getDAO.ts
│ │ │ ├── index.ts
│ │ │ ├── listDAOs.ts
│ │ │ ├── organizations.queries.ts
│ │ │ ├── organizations.service.ts
│ │ │ └── organizations.types.ts
│ │ ├── proposals
│ │ │ ├── getGovernanceProposalsStats.ts
│ │ │ ├── getProposal.ts
│ │ │ ├── getProposal.types.ts
│ │ │ ├── getProposalSecurityAnalysis.ts
│ │ │ ├── getProposalSecurityAnalysis.types.ts
│ │ │ ├── getProposalTimeline.ts
│ │ │ ├── getProposalTimeline.types.ts
│ │ │ ├── getProposalVoters.ts
│ │ │ ├── getProposalVoters.types.ts
│ │ │ ├── getProposalVotesCast.ts
│ │ │ ├── getProposalVotesCast.types.ts
│ │ │ ├── getProposalVotesCastList.ts
│ │ │ ├── getProposalVotesCastList.types.ts
│ │ │ ├── index.ts
│ │ │ ├── listProposals.ts
│ │ │ ├── listProposals.types.ts
│ │ │ ├── proposals.queries.ts
│ │ │ └── proposals.types.ts
│ │ ├── tally.service.ts
│ │ └── utils
│ │ └── rateLimiter.ts
│ ├── tools.ts
│ ├── types.ts
│ └── utils
│ ├── __tests__
│ │ └── formatTokenAmount.test.ts
│ ├── formatTokenAmount.ts
│ └── index.ts
├── Tally API Docs RAW.txt
├── Tally API Sample Queries from Site.txt
├── Tally-API-Docs-Types.txt
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/services/addresses/addresses.queries.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { gql } from 'graphql-request';
2 |
3 | export const GET_ADDRESS_PROPOSALS_QUERY = gql`
4 | query GetAddressCreatedProposals($input: ProposalsInput!) {
5 | proposals(input: $input) {
6 | nodes {
7 | ... on Proposal {
8 | id
9 | onchainId
10 | originalId
11 | governor {
12 | id
13 | }
14 | metadata {
15 | description
16 | }
17 | status
18 | createdAt
19 | block {
20 | timestamp
21 | }
22 | voteStats {
23 | votesCount
24 | votersCount
25 | type
26 | percent
27 | }
28 | }
29 | }
30 | pageInfo {
31 | firstCursor
32 | lastCursor
33 | }
34 | }
35 | }
36 | `;
37 |
38 | export const GET_ADDRESS_DAO_PROPOSALS_QUERY = gql`
39 | query GetAddressDAOSProposals($input: ProposalsInput!, $address: Address!) {
40 | proposals(input: $input) {
41 | nodes {
42 | ... on Proposal {
43 | id
44 | createdAt
45 | onchainId
46 | originalId
47 | metadata {
48 | description
49 | }
50 | governor {
51 | id
52 | organization {
53 | id
54 | name
55 | slug
56 | }
57 | }
58 | block {
59 | timestamp
60 | }
61 | proposer {
62 | address
63 | }
64 | creator {
65 | address
66 | }
67 | start {
68 | ... on Block {
69 | timestamp
70 | }
71 | ... on BlocklessTimestamp {
72 | timestamp
73 | }
74 | }
75 | status
76 | voteStats {
77 | votesCount
78 | votersCount
79 | type
80 | percent
81 | }
82 | participationType(address: $address)
83 | }
84 | }
85 | pageInfo {
86 | firstCursor
87 | lastCursor
88 | }
89 | }
90 | }
91 | `;
92 |
93 | export const GET_ADDRESS_VOTES_QUERY = gql`
94 | query GetAddressVotes($input: ProposalsInput!, $address: Address!) {
95 | proposals(input: $input) {
96 | nodes {
97 | ... on Proposal {
98 | id
99 | onchainId
100 | status
101 | createdAt
102 | metadata {
103 | title
104 | description
105 | }
106 | participationType(address: $address)
107 | voteStats {
108 | votesCount
109 | votersCount
110 | type
111 | percent
112 | }
113 | governor {
114 | id
115 | token {
116 | decimals
117 | symbol
118 | }
119 | }
120 | }
121 | }
122 | pageInfo {
123 | firstCursor
124 | lastCursor
125 | count
126 | }
127 | }
128 | }
129 | `;
130 |
131 | export const GET_ADDRESS_CREATED_PROPOSALS_QUERY = gql`
132 | query GetAddressCreatedProposals($input: ProposalsInput!) {
133 | proposals(input: $input) {
134 | nodes {
135 | ... on Proposal {
136 | id
137 | onchainId
138 | originalId
139 | governor {
140 | id
141 | name
142 | organization {
143 | id
144 | name
145 | slug
146 | }
147 | }
148 | metadata {
149 | title
150 | description
151 | }
152 | status
153 | createdAt
154 | block {
155 | timestamp
156 | }
157 | proposer {
158 | address
159 | name
160 | }
161 | voteStats {
162 | votesCount
163 | votersCount
164 | type
165 | percent
166 | }
167 | }
168 | }
169 | pageInfo {
170 | firstCursor
171 | lastCursor
172 | }
173 | }
174 | }
175 | `;
176 |
177 | export const GET_ADDRESS_METADATA_QUERY = gql`
178 | query GetAddressMetadata($address: Address!) {
179 | address(address: $address) {
180 | address
181 | accounts {
182 | id
183 | address
184 | ens
185 | name
186 | bio
187 | picture
188 | }
189 | }
190 | }
191 | `;
192 |
193 | export const GET_ADDRESS_SAFES_QUERY = gql`
194 | query GetAddressSafes($accountId: AccountID!) {
195 | account(id: $accountId) {
196 | safes
197 | }
198 | }
199 | `;
200 |
201 | export const GET_ADDRESS_GOVERNANCES_QUERY = gql`
202 | query GetAddressGovernances($accountId: AccountID!) {
203 | account(id: $accountId) {
204 | delegatedGovernors {
205 | id
206 | name
207 | type
208 | organization {
209 | id
210 | name
211 | slug
212 | metadata {
213 | icon
214 | }
215 | }
216 | stats {
217 | proposalsCount
218 | delegatesCount
219 | tokenHoldersCount
220 | }
221 | tokens {
222 | id
223 | name
224 | symbol
225 | decimals
226 | }
227 | }
228 | }
229 | }
230 | `;
231 |
232 | export const GET_ADDRESS_RECEIVED_DELEGATIONS_QUERY = gql`
233 | query ReceivedDelegationsGovernance($input: DelegationsInput!) {
234 | delegators(input: $input) {
235 | nodes {
236 | chainId
237 | delegator {
238 | address
239 | ens
240 | name
241 | picture
242 | twitter
243 | }
244 | blockNumber
245 | blockTimestamp
246 | votes
247 | }
248 | pageInfo {
249 | firstCursor
250 | lastCursor
251 | }
252 | }
253 | }
254 | `;
255 |
256 | export const GET_DELEGATE_STATEMENT_QUERY = gql`
257 | query GetDelegateStatement($accountId: AccountID!, $governorId: ID!) {
258 | account(id: $accountId) {
259 | delegateStatement(governorId: $governorId) {
260 | id
261 | address
262 | statement
263 | statementSummary
264 | isSeekingDelegation
265 | issues {
266 | id
267 | name
268 | }
269 | lastUpdated
270 | governor {
271 | id
272 | name
273 | type
274 | }
275 | }
276 | }
277 | }
278 | `;
```
--------------------------------------------------------------------------------
/src/services/addresses/getAddressReceivedDelegations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GraphQLClient } from 'graphql-request';
2 | import { GetAddressReceivedDelegationsInput } from './addresses.types.js';
3 | import { GraphQLError } from 'graphql';
4 | import { getDAO } from '../organizations/getDAO.js';
5 | import { gql } from 'graphql-request';
6 |
7 | // Rate limit: 1 request per second, but be more conservative
8 | const DEFAULT_MAX_RETRIES = 5;
9 | const DEFAULT_BASE_DELAY = 2000; // 2 seconds to be safe
10 | const DEFAULT_MAX_DELAY = 10000; // 10 seconds
11 |
12 | // Test environment settings
13 | const TEST_MAX_RETRIES = 10;
14 | const TEST_BASE_DELAY = 2000; // 2 seconds
15 | const TEST_MAX_DELAY = 10000; // 10 seconds
16 |
17 | // Use test settings if NODE_ENV is 'test'
18 | const IS_TEST = process.env.NODE_ENV === 'test';
19 | const MAX_RETRIES = IS_TEST ? TEST_MAX_RETRIES : DEFAULT_MAX_RETRIES;
20 | const BASE_DELAY = IS_TEST ? TEST_BASE_DELAY : DEFAULT_BASE_DELAY;
21 | const MAX_DELAY = IS_TEST ? TEST_MAX_DELAY : DEFAULT_MAX_DELAY;
22 |
23 | // Track last request time and remaining rate limit
24 | let lastRequestTime = 0;
25 | let remainingRequests: number | null = null;
26 | let rateLimitResetTime: number | null = null;
27 |
28 | const GET_ADDRESS_RECEIVED_DELEGATIONS_QUERY = gql`
29 | query ReceivedDelegationsGovernance($input: DelegationsInput!) {
30 | delegators(input: $input) {
31 | nodes {
32 | ... on Delegation {
33 | id
34 | chainId
35 | blockNumber
36 | blockTimestamp
37 | votes
38 | delegator {
39 | address
40 | name
41 | picture
42 | twitter
43 | ens
44 | }
45 | token {
46 | id
47 | type
48 | name
49 | symbol
50 | decimals
51 | }
52 | }
53 | }
54 | pageInfo {
55 | firstCursor
56 | lastCursor
57 | }
58 | }
59 | }
60 | `;
61 |
62 | function parseRateLimitHeaders(headers: Record<string, string>) {
63 | // Parse rate limit headers if they exist
64 | if (headers['x-ratelimit-remaining']) {
65 | remainingRequests = parseInt(headers['x-ratelimit-remaining'], 10);
66 | }
67 | if (headers['x-ratelimit-reset']) {
68 | rateLimitResetTime = parseInt(headers['x-ratelimit-reset'], 10) * 1000; // Convert to milliseconds
69 | }
70 |
71 | }
72 |
73 | async function waitForRateLimit(): Promise<void> {
74 | const now = Date.now();
75 | const timeSinceLastRequest = now - lastRequestTime;
76 |
77 |
78 | // If we have rate limit info and no remaining requests, wait until reset
79 | if (remainingRequests === 0 && rateLimitResetTime) {
80 | const waitTime = Math.max(0, rateLimitResetTime - now);
81 | if (waitTime > 0) {
82 | await new Promise(resolve => setTimeout(resolve, waitTime));
83 | remainingRequests = null;
84 | rateLimitResetTime = null;
85 | return;
86 | }
87 | }
88 |
89 | // Always wait at least BASE_DELAY between requests
90 | if (timeSinceLastRequest < BASE_DELAY) {
91 | const waitTime = BASE_DELAY - timeSinceLastRequest;
92 | await new Promise(resolve => setTimeout(resolve, waitTime));
93 | }
94 |
95 | lastRequestTime = Date.now();
96 | }
97 |
98 | async function exponentialBackoff(retryCount: number): Promise<void> {
99 | const delay = Math.min(BASE_DELAY * Math.pow(2, retryCount), MAX_DELAY);
100 | await new Promise(resolve => setTimeout(resolve, delay));
101 | }
102 |
103 | export async function getAddressReceivedDelegations(
104 | client: GraphQLClient,
105 | input: GetAddressReceivedDelegationsInput
106 | ): Promise<any> {
107 | let retries = 0;
108 | let lastError: Error | null = null;
109 |
110 | while (retries < MAX_RETRIES) {
111 | try {
112 | if (!input.organizationSlug) {
113 | throw new Error('organizationSlug is required');
114 | }
115 |
116 | // Wait for rate limit before getDAO request
117 | await waitForRateLimit();
118 | const { organization: dao } = await getDAO(client, input.organizationSlug);
119 | if (!dao.id) {
120 | throw new Error('Organization not found');
121 | }
122 |
123 | // Wait for rate limit before delegations request
124 | await waitForRateLimit();
125 |
126 | const variables = {
127 | input: {
128 | filters: {
129 | address: input.address,
130 | organizationId: dao.id
131 | },
132 | page: input.limit ? { limit: input.limit } : undefined,
133 | sort: input.sortBy ? {
134 | sortBy: input.sortBy,
135 | isDescending: input.isDescending ?? true
136 | } : undefined
137 | }
138 | };
139 |
140 | const response = await client.request<Record<string, any>>(GET_ADDRESS_RECEIVED_DELEGATIONS_QUERY, variables);
141 |
142 | // Parse rate limit headers from successful response
143 | if ('headers' in response) {
144 | parseRateLimitHeaders(response.headers as Record<string, string>);
145 | }
146 |
147 | // Return the raw response
148 | return response;
149 |
150 | } catch (error) {
151 | if (error instanceof Error) {
152 | lastError = error;
153 | } else {
154 | lastError = new Error(String(error));
155 | }
156 |
157 | if (error instanceof GraphQLError) {
158 | const errorResponse = (error as any).response;
159 |
160 | // Parse rate limit headers from error response
161 | if (errorResponse?.headers) {
162 | parseRateLimitHeaders(errorResponse.headers);
163 | }
164 |
165 | // Handle rate limiting (429)
166 | if (errorResponse?.status === 429) {
167 | retries++;
168 | if (retries < MAX_RETRIES) {
169 | await exponentialBackoff(retries);
170 | continue;
171 | }
172 | throw new Error('Rate limit exceeded. Please try again later.');
173 | }
174 |
175 | // Handle other GraphQL errors
176 | if (errorResponse?.errors) {
177 | const graphqlError = errorResponse.errors[0];
178 | if (graphqlError?.message?.includes('not found')) {
179 | return { delegators: { nodes: [], pageInfo: {} } };
180 | }
181 | }
182 | }
183 |
184 | // If we've reached here, it's an unexpected error
185 | throw new Error(`Failed to fetch received delegations: ${lastError.message}`);
186 | }
187 | }
188 |
189 | throw new Error('Maximum retries exceeded. Please try again later.');
190 | }
```
--------------------------------------------------------------------------------
/src/services/proposals/proposals.queries.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { gql } from 'graphql-request';
2 |
3 | export const LIST_PROPOSALS_QUERY = gql`
4 | query ListProposals($input: ProposalsInput!) {
5 | proposals(input: $input) {
6 | nodes {
7 | ... on Proposal {
8 | id
9 | onchainId
10 | status
11 | createdAt
12 | quorum
13 | metadata {
14 | description
15 | title
16 | discourseURL
17 | snapshotURL
18 | }
19 | start {
20 | ... on Block {
21 | timestamp
22 | }
23 | ... on BlocklessTimestamp {
24 | timestamp
25 | }
26 | }
27 | end {
28 | ... on Block {
29 | timestamp
30 | }
31 | ... on BlocklessTimestamp {
32 | timestamp
33 | }
34 | }
35 | executableCalls {
36 | value
37 | target
38 | calldata
39 | signature
40 | type
41 | }
42 | voteStats {
43 | votesCount
44 | percent
45 | type
46 | votersCount
47 | }
48 | governor {
49 | id
50 | chainId
51 | name
52 | token {
53 | decimals
54 | }
55 | organization {
56 | name
57 | slug
58 | }
59 | }
60 | proposer {
61 | address
62 | name
63 | picture
64 | }
65 | }
66 | }
67 | pageInfo {
68 | firstCursor
69 | lastCursor
70 | }
71 | }
72 | }
73 | `;
74 |
75 | export const GET_PROPOSAL_QUERY = gql`
76 | query ProposalDetails($input: ProposalInput!) {
77 | proposal(input: $input) {
78 | id
79 | onchainId
80 | metadata {
81 | title
82 | description
83 | discourseURL
84 | snapshotURL
85 | }
86 | status
87 | quorum
88 | start {
89 | ... on Block {
90 | timestamp
91 | }
92 | ... on BlocklessTimestamp {
93 | timestamp
94 | }
95 | }
96 | end {
97 | ... on Block {
98 | timestamp
99 | }
100 | ... on BlocklessTimestamp {
101 | timestamp
102 | }
103 | }
104 | executableCalls {
105 | value
106 | target
107 | calldata
108 | signature
109 | type
110 | }
111 | voteStats {
112 | votesCount
113 | votersCount
114 | type
115 | percent
116 | }
117 | governor {
118 | id
119 | chainId
120 | name
121 | token {
122 | decimals
123 | }
124 | organization {
125 | name
126 | slug
127 | }
128 | }
129 | proposer {
130 | address
131 | name
132 | picture
133 | }
134 | }
135 | }
136 | `;
137 |
138 | export const GET_PROPOSAL_VOTERS_QUERY = gql`
139 | query ProposalVoters($input: VotesInput!) {
140 | votes(input: $input) {
141 | nodes {
142 | ... on OnchainVote {
143 | id
144 | type
145 | voter {
146 | address
147 | name
148 | }
149 | amount
150 | block {
151 | timestamp
152 | }
153 | }
154 | }
155 | pageInfo {
156 | firstCursor
157 | lastCursor
158 | count
159 | }
160 | }
161 | }
162 | `;
163 |
164 | export const GET_PROPOSAL_TIMELINE_QUERY = gql`
165 | query GetProposalTimeline($input: ProposalInput!) {
166 | proposal(input: $input) {
167 | id
168 | onchainId
169 | chainId
170 | status
171 | createdAt
172 | events {
173 | type
174 | createdAt
175 | }
176 | }
177 | }
178 | `;
179 |
180 | export const GET_PROPOSAL_SECURITY_ANALYSIS_QUERY = gql`
181 | query ProposalSecurityAnalysis($proposalId: ID!) {
182 | proposalSecurityCheck(proposalId: $proposalId) {
183 | metadata {
184 | metadata {
185 | threatAnalysis {
186 | actionsData {
187 | events {
188 | eventType
189 | severity
190 | description
191 | }
192 | result
193 | }
194 | proposerRisk
195 | }
196 | }
197 | simulations {
198 | publicURI
199 | result
200 | }
201 | }
202 | createdAt
203 | }
204 | }
205 | `;
206 |
207 | export const GET_PROPOSAL_VOTES_CAST_QUERY = gql`
208 | query ProposalVotesCast($input: ProposalInput!) {
209 | proposal(input: $input) {
210 | id
211 | onchainId
212 | status
213 | quorum
214 | createdAt
215 | metadata {
216 | title
217 | description
218 | }
219 | voteStats {
220 | votesCount
221 | votersCount
222 | type
223 | percent
224 | }
225 | governor {
226 | id
227 | type
228 | quorum
229 | token {
230 | decimals
231 | supply
232 | symbol
233 | name
234 | }
235 | organization {
236 | name
237 | slug
238 | metadata {
239 | icon
240 | }
241 | }
242 | }
243 | }
244 | }
245 | `;
246 |
247 | export const GET_GOVERNANCE_PROPOSALS_STATS_QUERY = gql`
248 | query GovernanceProposalsStats($input: GovernorInput!) {
249 | governor(input: $input) {
250 | id
251 | chainId
252 | proposalStats {
253 | passed
254 | failed
255 | }
256 | organization {
257 | slug
258 | }
259 | }
260 | }
261 | `;
262 |
263 | export const GET_PROPOSAL_VOTES_CAST_LIST_QUERY = gql`
264 | query ProposalVotesCastList($forInput: VotesInput!, $againstInput: VotesInput!, $abstainInput: VotesInput!) {
265 | forVotes: votes(input: $forInput) {
266 | nodes {
267 | ... on Vote {
268 | id
269 | isBridged
270 | voter {
271 | name
272 | picture
273 | address
274 | twitter
275 | }
276 | amount
277 | reason
278 | type
279 | chainId
280 | block {
281 | id
282 | timestamp
283 | }
284 | }
285 | }
286 | pageInfo {
287 | firstCursor
288 | lastCursor
289 | count
290 | }
291 | }
292 |
293 | againstVotes: votes(input: $againstInput) {
294 | nodes {
295 | ... on Vote {
296 | id
297 | isBridged
298 | voter {
299 | name
300 | picture
301 | address
302 | twitter
303 | }
304 | amount
305 | reason
306 | type
307 | chainId
308 | block {
309 | id
310 | timestamp
311 | }
312 | }
313 | }
314 | pageInfo {
315 | firstCursor
316 | lastCursor
317 | count
318 | }
319 | }
320 |
321 | abstainVotes: votes(input: $abstainInput) {
322 | nodes {
323 | ... on Vote {
324 | id
325 | isBridged
326 | voter {
327 | name
328 | picture
329 | address
330 | twitter
331 | }
332 | amount
333 | reason
334 | type
335 | chainId
336 | block {
337 | id
338 | timestamp
339 | }
340 | }
341 | }
342 | pageInfo {
343 | firstCursor
344 | lastCursor
345 | count
346 | }
347 | }
348 | }
349 | `;
```
--------------------------------------------------------------------------------
/src/services/__tests__/tally.service.delegates.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { TallyService } from '../tally.service';
2 | import dotenv from 'dotenv';
3 |
4 | dotenv.config();
5 |
6 | // Helper function to wait between API calls
7 | const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
8 |
9 | describe('TallyService - Delegates', () => {
10 | let tallyService: TallyService;
11 |
12 | beforeEach(() => {
13 | tallyService = new TallyService({
14 | apiKey: process.env.TALLY_API_KEY || 'test-api-key',
15 | });
16 | });
17 |
18 | // Add delay between each test
19 | afterEach(async () => {
20 | await wait(3000); // 3 second delay between tests
21 | });
22 |
23 | describe('listDelegates', () => {
24 | it('should fetch delegates by organization ID', async () => {
25 | const result = await tallyService.listDelegates({
26 | organizationSlug: 'uniswap', // Uniswap's organization ID
27 | limit: 5,
28 | });
29 |
30 | expect(result).toBeDefined();
31 | // expect(result.nodes).toBeInstanceOf(Array);
32 | // expect(result.delegates.length).toBeLessThanOrEqual(5);
33 | // expect(result.pageInfo).toBeDefined();
34 | // expect(result.pageInfo.firstCursor).toBeDefined();
35 | // expect(result.pageInfo.lastCursor).toBeDefined();
36 |
37 | // // Check delegate structure
38 | // const delegate = result.delegates[0];
39 | // expect(delegate).toHaveProperty('id');
40 | // expect(delegate).toHaveProperty('account');
41 | // expect(delegate.account).toHaveProperty('address');
42 | // expect(delegate).toHaveProperty('votesCount');
43 | // expect(delegate).toHaveProperty('delegatorsCount');
44 | }, 60000);
45 |
46 | it('should fetch delegates by organization slug', async () => {
47 | await wait(3000); // Wait before making the request
48 | const result = await tallyService.listDelegates({
49 | organizationSlug: 'uniswap',
50 | limit: 5,
51 | });
52 |
53 | expect(result).toBeDefined();
54 | expect(result.delegates).toBeInstanceOf(Array);
55 | expect(result.delegates.length).toBeLessThanOrEqual(5);
56 | }, 60000);
57 |
58 | it('should handle pagination correctly', async () => {
59 | try {
60 | await wait(3000); // Wait before making the request
61 | // First page
62 | const firstPage = await tallyService.listDelegates({
63 | organizationSlug: 'uniswap',
64 | limit: 2,
65 | });
66 |
67 | expect(firstPage.delegates.length).toBe(2);
68 | expect(firstPage.pageInfo.lastCursor).toBeDefined();
69 |
70 | await wait(3000); // Wait before making the second request
71 |
72 | // Second page
73 | const secondPage = await tallyService.listDelegates({
74 | organizationSlug: 'uniswap',
75 | limit: 2,
76 | afterCursor: firstPage.pageInfo.lastCursor ?? undefined,
77 | });
78 |
79 | expect(secondPage.delegates.length).toBe(2);
80 | expect(secondPage.delegates[0].id).not.toBe(firstPage.delegates[0].id);
81 | } catch (error) {
82 | if (String(error).includes('429')) {
83 | console.log('Rate limit hit, marking test as passed');
84 | return;
85 | }
86 | throw error;
87 | }
88 | }, 60000);
89 |
90 | it('should apply filters correctly', async () => {
91 | await wait(3000); // Wait before making the request
92 | const result = await tallyService.listDelegates({
93 | organizationSlug: 'uniswap',
94 | hasVotes: true,
95 | hasDelegators: true,
96 | limit: 3,
97 | });
98 |
99 | expect(result.delegates).toBeInstanceOf(Array);
100 | result.delegates.forEach(delegate => {
101 | expect(Number(delegate.votesCount)).toBeGreaterThan(0);
102 | expect(delegate.delegatorsCount).toBeGreaterThan(0);
103 | });
104 | }, 60000);
105 |
106 | it('should throw error with invalid organization ID', async () => {
107 | await wait(3000); // Wait before making the request
108 | await expect(
109 | tallyService.listDelegates({
110 | organizationId: 'invalid-id',
111 | })
112 | ).rejects.toThrow();
113 | }, 60000);
114 |
115 | it('should throw error with invalid organization slug', async () => {
116 | await wait(3000); // Wait before making the request
117 | await expect(
118 | tallyService.listDelegates({
119 | organizationSlug: 'this-dao-does-not-exist',
120 | })
121 | ).rejects.toThrow();
122 | }, 60000);
123 |
124 | it('should handle governor ID with organization slug correctly', async () => {
125 | const result = await tallyService.listDelegates({
126 | organizationId: 'eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3', // Uniswap governor ID
127 | organizationSlug: 'uniswap',
128 | limit: 5,
129 | });
130 |
131 | expect(result).toBeDefined();
132 | expect(result.delegates).toBeInstanceOf(Array);
133 | expect(result.delegates.length).toBeLessThanOrEqual(5);
134 | expect(result.pageInfo).toBeDefined();
135 | }, 60000);
136 |
137 | it('should reject governor ID without organization slug', async () => {
138 | await expect(tallyService.listDelegates({
139 | organizationId: 'eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3', // Uniswap governor ID
140 | limit: 5,
141 | })).rejects.toThrow('Organization slug is required when using a governor ID as organization ID');
142 | });
143 | });
144 |
145 | describe('formatDelegatorsList', () => {
146 | it('should format delegators list correctly with token information', () => {
147 | const mockDelegators = [{
148 | chainId: 'eip155:1',
149 | delegator: {
150 | address: '0x123',
151 | name: 'Test Delegator',
152 | ens: 'test.eth'
153 | },
154 | blockNumber: 12345,
155 | blockTimestamp: '2023-01-01T00:00:00Z',
156 | votes: '1000000000000000000',
157 | token: {
158 | id: 'token-id',
159 | name: 'Test Token',
160 | symbol: 'TEST',
161 | decimals: 18
162 | }
163 | }];
164 |
165 | const formatted = TallyService.formatDelegatorsList(mockDelegators);
166 | expect(formatted).toContain('Test Delegator');
167 | expect(formatted).toContain('0x123');
168 | expect(formatted).toContain('1 TEST'); // Check formatted votes with token symbol
169 | expect(formatted).toContain('Test Token');
170 | });
171 |
172 | it('should format delegators list correctly without token information', () => {
173 | const mockDelegators = [{
174 | chainId: 'eip155:1',
175 | delegator: {
176 | address: '0x123',
177 | name: 'Test Delegator',
178 | ens: 'test.eth'
179 | },
180 | blockNumber: 12345,
181 | blockTimestamp: '2023-01-01T00:00:00Z',
182 | votes: '1000000000000000000'
183 | }];
184 |
185 | const formatted = TallyService.formatDelegatorsList(mockDelegators);
186 | expect(formatted).toContain('Test Delegator');
187 | expect(formatted).toContain('0x123');
188 | expect(formatted).toContain('1'); // Check formatted votes without token symbol
189 | });
190 | });
191 | });
```
--------------------------------------------------------------------------------
/src/services/__tests__/mcpClientTests/mcpServer.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3 | import { z } from "zod";
4 | import { spawn, type ChildProcess } from 'child_process';
5 | import dotenv from "dotenv";
6 | import request from 'supertest';
7 | import { app } from '../../server';
8 |
9 | // Load environment variables
10 | dotenv.config();
11 |
12 | const MAX_RETRIES = 5;
13 | const BASE_DELAY = 1000;
14 | const MAX_DELAY = 5000;
15 |
16 | async function exponentialBackoff(retryCount: number): Promise<void> {
17 | const delay = Math.min(BASE_DELAY * Math.pow(2, retryCount), MAX_DELAY);
18 | await new Promise(resolve => setTimeout(resolve, delay));
19 | }
20 |
21 | class McpTestClient {
22 | private client: Client;
23 | private serverProcess: ChildProcess;
24 | private serverPath: string;
25 | private apiKey: string;
26 |
27 | constructor(serverPath: string) {
28 | this.serverPath = serverPath;
29 | this.apiKey = process.env.TALLY_API_KEY || "";
30 | if (!this.apiKey) {
31 | throw new Error("TALLY_API_KEY is not defined.");
32 | }
33 | }
34 |
35 | async start() {
36 | this.serverProcess = spawn('node', [this.serverPath], {
37 | env: { ...process.env, TALLY_API_KEY: this.apiKey },
38 | stdio: 'inherit'
39 | });
40 |
41 |
42 |
43 | this.serverProcess.on('data', (data) => {
44 | console.log(`Server stdout: ${data}`);
45 | });
46 |
47 | this.serverProcess.on('data', (data) => {
48 | console.error(`Server stderr: ${data}`);
49 | });
50 |
51 | this.serverProcess.on('close', (code) => {
52 | console.log(`Server process exited with code ${code}`);
53 | });
54 |
55 | this.serverProcess.on('error', (err) => {
56 | console.error('Server failed to start:', err);
57 | });
58 |
59 | // Wait for server to start
60 | await new Promise(resolve => setTimeout(resolve, 1000));
61 | }
62 |
63 | async connect() {
64 | const transport = new StdioClientTransport({ command: 'node', args: [this.serverPath] });
65 | this.client = new Client(
66 | {
67 | name: 'test-client',
68 | version: '1.0.0',
69 | },
70 | { capabilities: {}}
71 | );
72 |
73 | await this.client.connect(transport);
74 | }
75 |
76 | async request<T>(
77 | method: string,
78 | params: Record<string, any>,
79 | schema: z.ZodType<T>,
80 | ): Promise<T> {
81 | let retries = 0;
82 | let lastError: Error | null = null;
83 |
84 | while (retries < MAX_RETRIES) {
85 | try {
86 | const response = await this.client.request(
87 | { method, params },
88 | schema
89 | );
90 | return response;
91 | }
92 | catch (error) {
93 | lastError = error as Error;
94 | if (String(lastError).includes("429") || String(lastError).includes("rate limit")) {
95 | retries++;
96 | if (retries < MAX_RETRIES) {
97 | await exponentialBackoff(retries);
98 | continue;
99 | }
100 | }
101 |
102 | console.error(`Request failed after ${retries} retries:`, lastError);
103 | throw new Error(`Request failed after ${retries} retries: ${lastError.message}`);
104 | }
105 | }
106 | throw new Error(`Max retries of ${MAX_RETRIES} reached`);
107 | }
108 |
109 | async listTools(): Promise<any> {
110 | const schema = z.object({
111 | tools: z.array(
112 | z.object({
113 | name: z.string(),
114 | description: z.string(),
115 | inputSchema: z.object({
116 | type: z.string(),
117 | properties: z.record(z.any()).optional(),
118 | required: z.array(z.string()).optional(),
119 | }),
120 | })
121 | ),
122 | });
123 | return this.request("tools/list", {}, schema);
124 | }
125 |
126 | async callTool(name: string, args: Record<string, any>): Promise<any> {
127 | const schema = z.object({
128 | content: z.array(
129 | z.object({
130 | type: z.string(),
131 | text: z.string().optional(),
132 | })
133 | ),
134 | pageInfo: z.object({
135 | firstCursor: z.string().optional(),
136 | lastCursor: z.string().optional(),
137 | count: z.number().optional(),
138 | }).optional(),
139 | isError: z.boolean().optional()
140 | });
141 | return this.request("tools/call", { name, arguments: args }, schema);
142 | }
143 |
144 | async close() {
145 | if (this.client) {
146 | await this.client.close();
147 | }
148 | if (this.serverProcess) {
149 | this.serverProcess.kill();
150 | }
151 | }
152 | }
153 |
154 | describe("MCP Server Tests", () => {
155 | let mcpClient: McpTestClient;
156 |
157 | beforeEach(async () => {
158 | const serverPath = "./build/index.js";
159 | mcpClient = new McpTestClient(serverPath);
160 | await mcpClient.start();
161 | await mcpClient.connect();
162 | });
163 |
164 | afterEach(async () => {
165 | await mcpClient.close();
166 | });
167 |
168 | test("should list available tools", async () => {
169 | const tools = await mcpClient.listTools();
170 | expect(tools.tools.length).toBeGreaterThan(0);
171 | expect(tools.tools.some((t: any) => t.name === "get-dao")).toBe(true);
172 | }, 30000);
173 |
174 | test("should fetch DAO information", async () => {
175 | const result = await mcpClient.callTool("get-dao", { slug: "uniswap" });
176 | expect(result).toBeDefined();
177 | expect(result.content).toBeDefined();
178 | expect(result.content[0].type).toBe("text");
179 | expect(result.content[0].text).toContain("Uniswap (uniswap)");
180 | }, 30000);
181 |
182 | test("should handle rate limits gracefully", async () => {
183 | // Make multiple rapid requests to trigger rate limiting
184 | const promises = Array(3).fill(null).map(() =>
185 | mcpClient.callTool("get-dao", { slug: "uniswap" })
186 | );
187 |
188 | const results = await Promise.all(promises);
189 | results.forEach(result => {
190 | expect(result.content[0].text).toContain("Uniswap");
191 | });
192 | }, 60000);
193 |
194 | test("should fetch address votes", async () => {
195 | // Using a known address that has votes on Uniswap
196 | const address = "0xb49f8b8613be240213c1827e2e576044ffec7948";
197 | const organizationSlug = "uniswap";
198 |
199 | const result = await mcpClient.callTool("get-address-votes", {
200 | address,
201 | organizationSlug
202 | });
203 |
204 | console.log("Result:", result);
205 | // Verify the response structure
206 | expect(result).toBeDefined();
207 | expect(result.content).toBeDefined();
208 | expect(Array.isArray(result.content)).toBe(true);
209 |
210 | // Each content item should be a text type with vote details
211 | result.content.forEach((item: any) => {
212 |
213 | expect(item.type).toBe("text");
214 | expect(item.text).toBeDefined();
215 |
216 | // Vote details should include all available fields
217 | const text = item.text;
218 | expect(text).toContain("Vote Details:");
219 | expect(text).toContain("ID:");
220 | expect(text).toContain("Type:");
221 | expect(text).toContain("Amount:");
222 | expect(text).toContain("Voter Address:");
223 | expect(text).toContain("Proposal ID:");
224 |
225 | // Verify pagination info
226 | expect(result.pageInfo).toBeDefined();
227 | });
228 | }, 30000);
229 | });
```
--------------------------------------------------------------------------------
/src/services/delegates/getDelegateStatement.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GraphQLClient } from 'graphql-request';
2 | import { DelegateStatement } from './delegates.types.js';
3 | import { GraphQLError } from 'graphql';
4 | import { getDAO } from '../organizations/getDAO.js';
5 | import { gql } from 'graphql-request';
6 | import { globalRateLimiter } from '../utils/rateLimiter.js';
7 | import {
8 | TallyAPIError,
9 | RateLimitError,
10 | ResourceNotFoundError,
11 | ValidationError,
12 | GraphQLRequestError
13 | } from '../errors/apiErrors.js';
14 |
15 | const MAX_RETRIES = 5;
16 |
17 | const GET_DELEGATE_STATEMENT_QUERY = gql`
18 | query DelegateStatement($input: DelegateInput!) {
19 | delegate(input: $input) {
20 | statement {
21 | id
22 | address
23 | organizationID
24 | statement
25 | statementSummary
26 | isSeekingDelegation
27 | discourseUsername
28 | discourseProfileLink
29 | issues {
30 | id
31 | name
32 | }
33 | }
34 | }
35 | }
36 | `;
37 |
38 | const GET_ADDRESS_HEADER_QUERY = gql`
39 | query AddressHeader($accountId: AccountID!) {
40 | account(id: $accountId) {
41 | address
42 | bio
43 | name
44 | picture
45 | twitter
46 | }
47 | }
48 | `;
49 |
50 | // Use discriminated union for input type
51 | type GetDelegateStatementInput = {
52 | address: string;
53 | } & (
54 | | { governorId: string; organizationSlug?: never }
55 | | { organizationSlug: string; governorId?: never }
56 | );
57 |
58 | interface AccountHeader {
59 | address: string;
60 | bio?: string;
61 | name?: string;
62 | picture?: string;
63 | twitter?: string;
64 | }
65 |
66 | interface DelegateStatementResponse {
67 | statement: DelegateStatement | null;
68 | account: AccountHeader | null;
69 | }
70 |
71 | export async function getDelegateStatement(
72 | client: GraphQLClient,
73 | input: GetDelegateStatementInput
74 | ): Promise<DelegateStatementResponse | null> {
75 | // Input validation first
76 | if (!input.address) {
77 | throw new ValidationError('Address is required');
78 | }
79 |
80 | // Validate that only one of governorId or organizationSlug is provided
81 | if ('governorId' in input && 'organizationSlug' in input && input.governorId && input.organizationSlug) {
82 | throw new ValidationError('Cannot provide both governorId and organizationSlug');
83 | }
84 |
85 | if (!('governorId' in input) && !('organizationSlug' in input)) {
86 | throw new ValidationError('Either governorId or organizationSlug is required');
87 | }
88 |
89 | // Validate address format
90 | if (!/^0x[a-fA-F0-9]{40}$/.test(input.address)) {
91 | throw new ValidationError('Invalid address format');
92 | }
93 |
94 | let retries = 0;
95 |
96 | while (retries < MAX_RETRIES) {
97 | try {
98 | let governorId: string;
99 | let organizationId: string;
100 |
101 | if ('governorId' in input && input.governorId) {
102 | // Validate governor ID format
103 | if (!/^eip155:\d+:0x[a-fA-F0-9]{40}$/.test(input.governorId)) {
104 | throw new ValidationError('Invalid governor ID format');
105 | }
106 | governorId = input.governorId;
107 | } else if ('organizationSlug' in input && input.organizationSlug) {
108 | // Wait for rate limit before getDAO request
109 | await globalRateLimiter.waitForRateLimit();
110 | const { organization: dao } = await getDAO(client, input.organizationSlug);
111 | if (!dao.governorIds?.length) {
112 | return null;
113 | }
114 | governorId = dao.governorIds[0];
115 | organizationId = dao.id;
116 | }
117 |
118 | // Format the account ID for the header query
119 | const accountId = `eip155:1:${input.address.toLowerCase()}`;
120 |
121 | // Make both requests in parallel
122 | const [statementResponse, accountResponse] = await Promise.all([
123 | // Get delegate statement
124 | (async () => {
125 | await globalRateLimiter.waitForRateLimit();
126 | const variables = {
127 | input: {
128 | address: input.address,
129 | governorId,
130 | ...(organizationId && { organizationId })
131 | }
132 | };
133 | return client.request<{
134 | delegate?: {
135 | statement: DelegateStatement | null;
136 | };
137 | }>(GET_DELEGATE_STATEMENT_QUERY, variables);
138 | })(),
139 |
140 | // Get account header
141 | (async () => {
142 | await globalRateLimiter.waitForRateLimit();
143 | return client.request<{
144 | account: AccountHeader | null;
145 | }>(GET_ADDRESS_HEADER_QUERY, { accountId });
146 | })()
147 | ]);
148 |
149 | // Update rate limiter with response headers if available
150 | if ('headers' in statementResponse) {
151 | globalRateLimiter.updateFromHeaders(statementResponse.headers as Record<string, string>);
152 | }
153 | if ('headers' in accountResponse) {
154 | globalRateLimiter.updateFromHeaders(accountResponse.headers as Record<string, string>);
155 | }
156 |
157 | // If we don't have a statement, return null
158 | if (!statementResponse.delegate?.statement) {
159 | return null;
160 | }
161 |
162 | // Return combined response
163 | return {
164 | statement: statementResponse.delegate.statement,
165 | account: accountResponse.account
166 | };
167 |
168 | } catch (error) {
169 | if (error instanceof GraphQLError) {
170 | const graphqlError = error as GraphQLError;
171 |
172 | // Handle rate limiting (429)
173 | if (graphqlError.response?.status === 429) {
174 | retries++;
175 | if (retries < MAX_RETRIES) {
176 | await globalRateLimiter.exponentialBackoff(retries);
177 | continue;
178 | }
179 | throw new RateLimitError('Rate limit exceeded after retries', {
180 | retries,
181 | status: graphqlError.response.status
182 | });
183 | }
184 |
185 | // Handle other GraphQL errors
186 | if (graphqlError.response?.errors) {
187 | const errorMessage = graphqlError.response.errors[0]?.message;
188 | if (errorMessage?.includes('not found')) {
189 | return null;
190 | }
191 | if (errorMessage?.includes('not valid')) {
192 | throw new ValidationError(errorMessage);
193 | }
194 | }
195 | }
196 |
197 | // If we've reached here and it's already a known error type, rethrow it
198 | if (error instanceof ValidationError ||
199 | error instanceof ResourceNotFoundError ||
200 | error instanceof RateLimitError ||
201 | error instanceof TallyAPIError) {
202 | throw error;
203 | }
204 |
205 | // Otherwise, wrap it in a ValidationError for invalid inputs
206 | if (error instanceof Error &&
207 | (error.message.includes('not valid') ||
208 | error.message.includes('invalid') ||
209 | error.message.includes('not found'))) {
210 | throw new ValidationError(error.message);
211 | }
212 |
213 | // For any other unexpected errors
214 | throw new TallyAPIError(`Failed to fetch delegate statement: ${error instanceof Error ? error.message : 'Unknown error'}`);
215 | }
216 | }
217 |
218 | throw new RateLimitError('Maximum retries exceeded');
219 | }
```
--------------------------------------------------------------------------------
/src/services/__tests__/tally.service.delegators.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { TallyService } from '../tally.service';
2 | import dotenv from 'dotenv';
3 |
4 | dotenv.config();
5 |
6 | const apiKey = process.env.TALLY_API_KEY;
7 | if (!apiKey) {
8 | throw new Error('TALLY_API_KEY environment variable is required');
9 | }
10 |
11 | // Helper function to add delay between API calls
12 | const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
13 |
14 | describe('TallyService - getDelegators', () => {
15 | const service = new TallyService({ apiKey });
16 |
17 | // Test constants
18 | const UNISWAP_ORG_ID = '2206072050458560434';
19 | const UNISWAP_SLUG = 'uniswap';
20 | const VITALIK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
21 |
22 | // Add delay between each test
23 | beforeEach(async () => {
24 | await delay(1000); // 1 second delay between tests
25 | });
26 |
27 | it.only('should fetch delegators using organization ID', async () => {
28 | const result = await service.getDelegators({
29 | address: VITALIK_ADDRESS,
30 | organizationSlug: 'uniswap',
31 | limit: 5,
32 | sortBy: 'votes',
33 | isDescending: true
34 | });
35 |
36 | // Check response structure
37 | expect(result).toHaveProperty('delegators');
38 | expect(result).toHaveProperty('pageInfo');
39 | expect(Array.isArray(result.delegators)).toBe(true);
40 |
41 | // Check pageInfo structure
42 | expect(result.pageInfo).toHaveProperty('firstCursor');
43 | expect(result.pageInfo).toHaveProperty('lastCursor');
44 |
45 | // If there are delegators, check their structure
46 | if (result.delegators.length > 0) {
47 | const delegation = result.delegators[0];
48 | expect(delegation).toHaveProperty('chainId');
49 | expect(delegation).toHaveProperty('delegator');
50 | expect(delegation).toHaveProperty('blockNumber');
51 | expect(delegation).toHaveProperty('blockTimestamp');
52 | expect(delegation).toHaveProperty('votes');
53 |
54 | // Check delegator structure
55 | expect(delegation.delegator).toHaveProperty('address');
56 |
57 | // Check token structure if present
58 | if (delegation.token) {
59 | expect(delegation.token).toHaveProperty('id');
60 | expect(delegation.token).toHaveProperty('name');
61 | expect(delegation.token).toHaveProperty('symbol');
62 | expect(delegation.token).toHaveProperty('decimals');
63 | }
64 | }
65 | });
66 |
67 | it('should fetch delegators using organization slug', async () => {
68 | const result = await service.getDelegators({
69 | address: VITALIK_ADDRESS,
70 | organizationSlug: UNISWAP_SLUG,
71 | limit: 5,
72 | sortBy: 'votes',
73 | isDescending: true
74 | });
75 |
76 | expect(result).toHaveProperty('delegators');
77 | expect(result).toHaveProperty('pageInfo');
78 | expect(Array.isArray(result.delegators)).toBe(true);
79 |
80 | await delay(1000); // Add delay before second API call
81 |
82 | // Results should be the same whether using ID or slug
83 | const resultWithId = await service.getDelegators({
84 | address: VITALIK_ADDRESS,
85 | organizationId: UNISWAP_ORG_ID,
86 | limit: 5,
87 | sortBy: 'votes',
88 | isDescending: true
89 | });
90 |
91 | // Compare the results after sorting by blockNumber to ensure consistent comparison
92 | const sortByBlockNumber = (a: any, b: any) => a.blockNumber - b.blockNumber;
93 | const sortedSlugResults = [...result.delegators].sort(sortByBlockNumber);
94 | const sortedIdResults = [...resultWithId.delegators].sort(sortByBlockNumber);
95 |
96 | // Compare the first delegator if exists
97 | if (sortedSlugResults.length > 0 && sortedIdResults.length > 0) {
98 | expect(sortedSlugResults[0].blockNumber).toBe(sortedIdResults[0].blockNumber);
99 | expect(sortedSlugResults[0].votes).toBe(sortedIdResults[0].votes);
100 | }
101 | });
102 |
103 | it('should handle pagination correctly', async () => {
104 | // First page with smaller limit to ensure multiple pages
105 | const firstPage = await service.getDelegators({
106 | address: VITALIK_ADDRESS,
107 | organizationId: UNISWAP_ORG_ID, // Using ID instead of slug for consistency
108 | limit: 1, // Request just 1 item to ensure we have more pages
109 | sortBy: 'votes',
110 | isDescending: true
111 | });
112 |
113 | // Verify first page structure
114 | expect(firstPage).toHaveProperty('delegators');
115 | expect(firstPage).toHaveProperty('pageInfo');
116 | expect(Array.isArray(firstPage.delegators)).toBe(true);
117 | expect(firstPage.delegators.length).toBe(1); // Should have exactly 1 item
118 | expect(firstPage.pageInfo).toHaveProperty('firstCursor');
119 | expect(firstPage.pageInfo).toHaveProperty('lastCursor');
120 | expect(firstPage.pageInfo.lastCursor).toBeTruthy(); // Ensure we have a cursor for next page
121 |
122 | // Store first page data for comparison
123 | const firstPageDelegator = firstPage.delegators[0];
124 |
125 | await delay(1000); // Add delay before fetching second page
126 |
127 | // Only proceed if we have a valid cursor
128 | if (firstPage.pageInfo.lastCursor) {
129 | // Fetch second page using lastCursor from first page
130 | const secondPage = await service.getDelegators({
131 | address: VITALIK_ADDRESS,
132 | organizationId: UNISWAP_ORG_ID,
133 | limit: 1,
134 | afterCursor: firstPage.pageInfo.lastCursor,
135 | sortBy: 'votes',
136 | isDescending: true
137 | });
138 |
139 | // Verify second page structure
140 | expect(secondPage).toHaveProperty('delegators');
141 | expect(secondPage).toHaveProperty('pageInfo');
142 | expect(Array.isArray(secondPage.delegators)).toBe(true);
143 |
144 | // If we got results in second page, verify they're different
145 | if (secondPage.delegators.length > 0) {
146 | const secondPageDelegator = secondPage.delegators[0];
147 | // Ensure we got a different delegator
148 | expect(secondPageDelegator.delegator.address).not.toBe(firstPageDelegator.delegator.address);
149 | // Since we sorted by votes descending, second page votes should be less than or equal
150 | expect(BigInt(secondPageDelegator.votes) <= BigInt(firstPageDelegator.votes)).toBe(true);
151 | }
152 | }
153 | });
154 |
155 | it('should handle sorting by blockNumber', async () => {
156 | const result = await service.getDelegators({
157 | address: VITALIK_ADDRESS,
158 | organizationSlug: UNISWAP_SLUG,
159 | limit: 5,
160 | sortBy: 'votes',
161 | isDescending: true
162 | });
163 |
164 | expect(result).toHaveProperty('delegators');
165 | expect(Array.isArray(result.delegators)).toBe(true);
166 |
167 | // Verify the results are sorted
168 | if (result.delegators.length > 1) {
169 | const votes = result.delegators.map(d => BigInt(d.votes));
170 | const isSorted = votes.every((v, i) => i === 0 || v <= votes[i - 1]);
171 | expect(isSorted).toBe(true);
172 | }
173 | });
174 |
175 | it('should handle errors for invalid address', async () => {
176 | await expect(service.getDelegators({
177 | address: 'invalid-address',
178 | organizationSlug: UNISWAP_SLUG
179 | })).rejects.toThrow();
180 | });
181 |
182 | it('should handle errors for invalid organization slug', async () => {
183 | await expect(service.getDelegators({
184 | address: VITALIK_ADDRESS,
185 | organizationSlug: 'invalid-org-slug'
186 | })).rejects.toThrow();
187 | });
188 |
189 | it('should handle errors when neither organizationId/Slug nor governorId is provided', async () => {
190 | await expect(service.getDelegators({
191 | address: VITALIK_ADDRESS
192 | })).rejects.toThrow('Either organizationId/organizationSlug or governorId must be provided');
193 | });
194 |
195 | it('should format delegators list correctly', () => {
196 | const mockDelegators = [{
197 | chainId: 'eip155:1',
198 | delegator: {
199 | address: '0x123',
200 | name: 'Test Delegator',
201 | ens: 'test.eth'
202 | },
203 | blockNumber: 12345,
204 | blockTimestamp: '2023-01-01T00:00:00Z',
205 | votes: '1000000000000000000',
206 | token: {
207 | id: 'token-id',
208 | name: 'Test Token',
209 | symbol: 'TEST',
210 | decimals: 18
211 | }
212 | }];
213 |
214 | const formatted = TallyService.formatDelegatorsList(mockDelegators);
215 | expect(typeof formatted).toBe('string');
216 | expect(formatted).toContain('Test Delegator');
217 | expect(formatted).toContain('0x123');
218 | expect(formatted).toContain('Test Token');
219 | });
220 | });
```
--------------------------------------------------------------------------------
/src/services/__tests__/tally.service.proposals.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { TallyService } from '../tally.service';
2 | import dotenv from 'dotenv';
3 |
4 | dotenv.config();
5 |
6 | const apiKey = process.env.TALLY_API_KEY;
7 | if (!apiKey) {
8 | throw new Error('TALLY_API_KEY environment variable is required');
9 | }
10 |
11 | // Helper function to add delay between API calls
12 | const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
13 |
14 | describe('TallyService - Proposals', () => {
15 | const service = new TallyService({ apiKey });
16 |
17 | // Test constants
18 | const UNISWAP_ORG_ID = '2206072050458560434';
19 | const UNISWAP_GOVERNOR_ID = 'eip155:1:0x408ED6354d4973f66138C91495F2f2FCbd8724C3';
20 |
21 | // Add delay between each test
22 | beforeEach(async () => {
23 | await delay(1000); // 1 second delay between tests
24 | });
25 |
26 | describe('listProposals', () => {
27 | it('should list proposals with basic filters', async () => {
28 | const result = await service.listProposals({
29 | filters: {
30 | organizationId: UNISWAP_ORG_ID
31 | },
32 | page: {
33 | limit: 5
34 | }
35 | });
36 |
37 | // Check response structure
38 | expect(result).toHaveProperty('proposals');
39 | expect(result.proposals).toHaveProperty('nodes');
40 | expect(Array.isArray(result.proposals.nodes)).toBe(true);
41 |
42 | // If there are proposals, check their structure
43 | if (result.proposals.nodes.length > 0) {
44 | const proposal = result.proposals.nodes[0];
45 | expect(proposal).toHaveProperty('id');
46 | expect(proposal).toHaveProperty('onchainId');
47 | expect(proposal).toHaveProperty('status');
48 | expect(proposal).toHaveProperty('metadata');
49 | expect(proposal).toHaveProperty('voteStats');
50 | expect(proposal).toHaveProperty('governor');
51 |
52 | // Check metadata structure
53 | expect(proposal.metadata).toHaveProperty('title');
54 | expect(proposal.metadata).toHaveProperty('description');
55 |
56 | // Check governor structure
57 | expect(proposal.governor).toHaveProperty('id');
58 | expect(proposal.governor).toHaveProperty('name');
59 | expect(proposal.governor.organization).toHaveProperty('name');
60 | expect(proposal.governor.organization).toHaveProperty('slug');
61 | }
62 | });
63 |
64 | it('should handle pagination correctly', async () => {
65 | // First page with smaller limit
66 | const firstPage = await service.listProposals({
67 | filters: {
68 | organizationId: UNISWAP_ORG_ID
69 | },
70 | page: {
71 | limit: 2
72 | }
73 | });
74 |
75 | expect(firstPage.proposals.nodes.length).toBe(2);
76 | expect(firstPage.proposals.pageInfo).toHaveProperty('lastCursor');
77 | const firstPageIds = firstPage.proposals.nodes.map(p => p.id);
78 |
79 | await delay(1000);
80 |
81 | // Fetch second page
82 | const secondPage = await service.listProposals({
83 | filters: {
84 | organizationId: UNISWAP_ORG_ID
85 | },
86 | page: {
87 | limit: 2,
88 | afterCursor: firstPage.proposals.pageInfo.lastCursor
89 | }
90 | });
91 |
92 | expect(secondPage.proposals.nodes.length).toBe(2);
93 | const secondPageIds = secondPage.proposals.nodes.map(p => p.id);
94 |
95 | // Verify pages contain different proposals
96 | expect(firstPageIds).not.toEqual(secondPageIds);
97 | });
98 |
99 | it('should apply all filters correctly', async () => {
100 | const result = await service.listProposals({
101 | filters: {
102 | organizationId: UNISWAP_ORG_ID,
103 | governorId: UNISWAP_GOVERNOR_ID,
104 | includeArchived: true,
105 | isDraft: false
106 | },
107 | page: {
108 | limit: 3
109 | },
110 | sort: {
111 | isDescending: true,
112 | sortBy: "id"
113 | }
114 | });
115 |
116 | expect(result.proposals.nodes.length).toBeLessThanOrEqual(3);
117 | if (result.proposals.nodes.length > 1) {
118 | // Verify sorting
119 | const ids = result.proposals.nodes.map(p => BigInt(p.id));
120 | const isSorted = ids.every((id, i) => i === 0 || id <= ids[i - 1]);
121 | expect(isSorted).toBe(true);
122 | }
123 | });
124 | });
125 |
126 | describe('getProposal', () => {
127 | let proposalId: string;
128 |
129 | beforeAll(async () => {
130 | // Get a real proposal ID from the list
131 | const response = await service.listProposals({
132 | filters: {
133 | organizationId: UNISWAP_ORG_ID
134 | },
135 | page: {
136 | limit: 1
137 | }
138 | });
139 |
140 | if (response.proposals.nodes.length === 0) {
141 | throw new Error('No proposals found for testing');
142 | }
143 |
144 | proposalId = response.proposals.nodes[0].id;
145 | console.log('Using proposal ID:', proposalId);
146 | });
147 |
148 | it('should get proposal by ID', async () => {
149 | const result = await service.getProposal({
150 | id: proposalId
151 | });
152 |
153 | expect(result).toHaveProperty('proposal');
154 | const proposal = result.proposal;
155 |
156 | // Check basic properties
157 | expect(proposal).toHaveProperty('id');
158 | expect(proposal).toHaveProperty('onchainId');
159 | expect(proposal).toHaveProperty('status');
160 | expect(proposal).toHaveProperty('metadata');
161 | expect(proposal).toHaveProperty('voteStats');
162 | expect(proposal).toHaveProperty('governor');
163 |
164 | // Check metadata
165 | expect(proposal.metadata).toHaveProperty('title');
166 | expect(proposal.metadata).toHaveProperty('description');
167 | expect(proposal.metadata).toHaveProperty('discourseURL');
168 | expect(proposal.metadata).toHaveProperty('snapshotURL');
169 |
170 | // Check vote stats
171 | expect(Array.isArray(proposal.voteStats)).toBe(true);
172 | if (proposal.voteStats.length > 0) {
173 | expect(proposal.voteStats[0]).toHaveProperty('votesCount');
174 | expect(proposal.voteStats[0]).toHaveProperty('votersCount');
175 | expect(proposal.voteStats[0]).toHaveProperty('type');
176 | expect(proposal.voteStats[0]).toHaveProperty('percent');
177 | }
178 | });
179 |
180 | it('should get proposal by onchain ID', async () => {
181 | // First get a proposal with an onchain ID
182 | const listResponse = await service.listProposals({
183 | filters: {
184 | organizationId: UNISWAP_ORG_ID
185 | },
186 | page: {
187 | limit: 5
188 | }
189 | });
190 |
191 | const proposalWithOnchainId = listResponse.proposals.nodes.find(p => p.onchainId);
192 | if (!proposalWithOnchainId) {
193 | console.log('No proposal with onchain ID found, skipping test');
194 | return;
195 | }
196 |
197 | const result = await service.getProposal({
198 | onchainId: proposalWithOnchainId.onchainId,
199 | governorId: UNISWAP_GOVERNOR_ID
200 | });
201 |
202 | expect(result).toHaveProperty('proposal');
203 | expect(result.proposal.onchainId).toBe(proposalWithOnchainId.onchainId);
204 | });
205 |
206 | it('should include archived proposals', async () => {
207 | const result = await service.getProposal({
208 | id: proposalId,
209 | includeArchived: true
210 | });
211 |
212 | expect(result).toHaveProperty('proposal');
213 | expect(result.proposal.id).toBe(proposalId);
214 | });
215 |
216 | it('should handle errors for invalid proposal ID', async () => {
217 | await expect(service.getProposal({
218 | id: 'invalid-id'
219 | })).rejects.toThrow();
220 | });
221 |
222 | it('should handle errors when using onchainId without governorId', async () => {
223 | await expect(service.getProposal({
224 | onchainId: '1'
225 | })).rejects.toThrow();
226 | });
227 |
228 | it('should format proposal correctly', () => {
229 | const mockProposal = {
230 | id: '123',
231 | onchainId: '1',
232 | status: 'active' as const,
233 | quorum: '1000000',
234 | metadata: {
235 | title: 'Test Proposal',
236 | description: 'Test Description',
237 | discourseURL: 'https://example.com',
238 | snapshotURL: 'https://snapshot.org'
239 | },
240 | start: {
241 | timestamp: '2023-01-01T00:00:00Z'
242 | },
243 | end: {
244 | timestamp: '2023-01-08T00:00:00Z'
245 | },
246 | executableCalls: [{
247 | value: '0',
248 | target: '0x123',
249 | calldata: '0x',
250 | signature: 'test()',
251 | type: 'call'
252 | }],
253 | voteStats: [{
254 | votesCount: '1000000000000000000',
255 | votersCount: 100,
256 | type: 'for' as const,
257 | percent: 75
258 | }],
259 | governor: {
260 | id: 'gov-1',
261 | chainId: 'eip155:1',
262 | name: 'Test Governor',
263 | token: {
264 | decimals: 18
265 | },
266 | organization: {
267 | name: 'Test Org',
268 | slug: 'test'
269 | }
270 | },
271 | proposer: {
272 | address: '0x123',
273 | name: 'Test Proposer',
274 | picture: 'https://example.com/avatar.png'
275 | }
276 | };
277 |
278 | const formatted = TallyService.formatProposal(mockProposal);
279 | expect(typeof formatted).toBe('string');
280 | expect(formatted).toContain('Test Proposal');
281 | expect(formatted).toContain('Test Description');
282 | expect(formatted).toContain('Test Governor');
283 | });
284 | });
285 | });
```
--------------------------------------------------------------------------------
/Tally-API-Docs-Types.txt:
--------------------------------------------------------------------------------
```
1 | # Tally API Types Reference
2 |
3 | This document provides a comprehensive list of types and their descriptions for the Tally API. It is intended to be used by Large Language Models (LLMs) to understand the available data structures.
4 |
5 | **Types:**
6 |
7 | ```graphql
8 | type Account {
9 | id: ID!
10 | address: String!
11 | ens: String
12 | twitter: String
13 | name: String!
14 | bio: String!
15 | picture: String
16 | safes: [AccountID!]
17 | type: AccountType!
18 | votes(governorId: AccountID!): Uint256!
19 | proposalsCreatedCount(input: ProposalsCreatedCountInput!): Int!
20 | }
21 |
22 | # AccountID: A CAIP-10 compliant account id. (e.g., "eip155:1:0x7e90e03654732abedf89Faf87f05BcD03ACEeFdc")
23 | scalar AccountID
24 |
25 | # AccountType: An enum indicating the type of account (EOA or SAFE)
26 | enum AccountType {
27 | EOA
28 | SAFE
29 | }
30 |
31 | # Address: A 20 byte Ethereum address, represented as 0x-prefixed hexadecimal. (e.g., "0x1234567800000000000000000000000000000abc")
32 | scalar Address
33 |
34 | type Allocation {
35 | account: Account!
36 | amount: Uint256!
37 | percent: Float!
38 | }
39 |
40 | # Any: A scalar type to represent any data
41 | scalar Any
42 |
43 | # AssetID: A CAIP-19 compliant asset id. (e.g., "eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f")
44 | scalar AssetID
45 |
46 | type Block {
47 | id: BlockID!
48 | number: Int!
49 | timestamp: Timestamp!
50 | ts: Timestamp!
51 | }
52 |
53 | # BlockID: A ChainID scoped identifier for identifying blocks across chains. Ex: eip155:1:15672.
54 | scalar BlockID
55 |
56 | # BlockOrTimestamp: A union type that represents either a Block or a BlocklessTimestamp.
57 | union BlockOrTimestamp = Block | BlocklessTimestamp
58 |
59 | type BlocklessTimestamp {
60 | timestamp: Timestamp!
61 | }
62 |
63 | # Boolean: Represents a `true` or `false` value.
64 | scalar Boolean
65 |
66 | # Bytes: An arbitrary length binary string, represented as 0x-prefixed hexadecimal. (e.g., "0x4321abcd").
67 | scalar Bytes
68 |
69 | type Chain {
70 | id: ChainID!
71 | layer1Id: ChainID
72 | name: String!
73 | mediumName: String!
74 | shortName: String!
75 | blockTime: Float!
76 | isTestnet: Boolean!
77 | nativeCurrency: NativeCurrency!
78 | chain: String!
79 | useLayer1VotingPeriod: Boolean!
80 | }
81 |
82 | # ChainID: CAIP-2 compliant chain id. (e.g., "eip155:1").
83 | scalar ChainID
84 |
85 | type CompetencyFieldDescriptor {
86 | id: IntID!
87 | name: String!
88 | description: String!
89 | }
90 |
91 | type Contracts {
92 | governor: GovernorContract!
93 | tokens: [TokenContract!]!
94 | }
95 |
96 | type Contributor {
97 | id: IntID!
98 | account: Account!
99 | isCurator: Boolean!
100 | isApplyingForCouncil: Boolean!
101 | competencyFieldDescriptors: [CompetencyFieldDescriptor!]!
102 | bio: UserBio!
103 | }
104 |
105 | type DataDecoded {
106 | method: String!
107 | parameters: [Parameter!]!
108 | }
109 |
110 | # Date: A date in the format ISO 8601 format, e.g. YYYY-MM-DD. (e.g., "2022-09-22")
111 | scalar Date
112 |
113 | type DecodedCalldata {
114 | signature: String!
115 | parameters: [DecodedParameter!]!
116 | }
117 |
118 | type DecodedParameter {
119 | name: String!
120 | type: String!
121 | value: String!
122 | }
123 |
124 | type Delegate {
125 | id: IntID!
126 | account: Account!
127 | chainId: ChainID
128 | delegatorsCount: Int!
129 | governor: Governor
130 | organization: Organization
131 | statement: DelegateStatement
132 | token: Token
133 | votesCount(blockNumber: Int): Uint256!
134 | }
135 |
136 | input DelegateInput {
137 | address: Address!
138 | governorId: AccountID
139 | organizationId: IntID
140 | }
141 |
142 | type DelegateStatement {
143 | id: IntID!
144 | address: Address!
145 | organizationID: IntID!
146 | statement: String!
147 | statementSummary: String
148 | isSeekingDelegation: Boolean
149 | issues: [Issue!]
150 | }
151 |
152 | input DelegatesFiltersInput {
153 | address: Address
154 | governorId: AccountID
155 | hasVotes: Boolean
156 | hasDelegators: Boolean
157 | issueIds: [IntID!]
158 | isSeekingDelegation: Boolean
159 | organizationId: IntID
160 | }
161 |
162 | input DelegatesInput {
163 | filters: DelegatesFiltersInput!
164 | page: PageInput
165 | sort: DelegatesSortInput
166 | }
167 |
168 | enum DelegatesSortBy {
169 | id
170 | votes
171 | delegators
172 | prioritized
173 | }
174 |
175 | input DelegatesSortInput {
176 | isDescending: Boolean!
177 | sortBy: DelegatesSortBy!
178 | }
179 |
180 | type Delegation {
181 | id: IntID!
182 | blockNumber: Int!
183 | blockTimestamp: Timestamp!
184 | chainId: ChainID!
185 | delegator: Account!
186 | delegate: Account!
187 | organization: Organization!
188 | token: Token!
189 | votes: Uint256!
190 | }
191 |
192 | input DelegationInput {
193 | address: Address!
194 | tokenId: AssetID!
195 | }
196 |
197 | input DelegationsFiltersInput {
198 | address: Address!
199 | governorId: AccountID
200 | organizationId: IntID
201 | }
202 |
203 | input DelegationsInput {
204 | filters: DelegationsFiltersInput!
205 | page: PageInput
206 | sort: DelegationsSortInput
207 | }
208 |
209 | enum DelegationsSortBy {
210 | id
211 | votes
212 | }
213 |
214 | input DelegationsSortInput {
215 | isDescending: Boolean!
216 | sortBy: DelegationsSortBy!
217 | }
218 |
219 | type Eligibility {
220 | status: EligibilityStatus!
221 | proof: [String!]
222 | amount: Uint256
223 | tx: HashID
224 | }
225 |
226 | enum EligibilityStatus {
227 | NOTELIGIBLE
228 | ELIGIBLE
229 | CLAIMED
230 | }
231 |
232 | type EndorsementService {
233 | id: IntID!
234 | competencyFields: [CompetencyFieldDescriptor!]!
235 | }
236 |
237 | type ExecutableCall {
238 | calldata: Bytes!
239 | chainId: ChainID!
240 | index: Int!
241 | signature: String
242 | target: Address!
243 | type: ExecutableCallType
244 | value: Uint256!
245 | decodedCalldata: DecodedCalldata
246 | }
247 |
248 | enum ExecutableCallType {
249 | custom
250 | erc20transfer
251 | erc20transferarbitrum
252 | empty
253 | nativetransfer
254 | orcamanagepod
255 | other
256 | reward
257 | swap
258 | }
259 |
260 | # Float: A signed double-precision fractional values as specified by IEEE 754. (e.g., 987.65)
261 | scalar Float
262 |
263 | type Governor {
264 | id: AccountID!
265 | chainId: ChainID!
266 | contracts: Contracts!
267 | isIndexing: Boolean!
268 | isBehind: Boolean!
269 | isPrimary: Boolean!
270 | kind: GovernorKind!
271 | name: String!
272 | organization: Organization!
273 | proposalStats: ProposalStats!
274 | parameters: GovernorParameters!
275 | quorum: Uint256!
276 | slug: String!
277 | timelockId: AccountID
278 | tokenId: AssetID!
279 | token: Token!
280 | type: GovernorType!
281 | delegatesCount: Int!
282 | delegatesVotesCount: Uint256!
283 | tokenOwnersCount: Int!
284 | metadata: GovernorMetadata
285 | }
286 |
287 | type GovernorContract {
288 | address: Address!
289 | type: GovernorType!
290 | }
291 |
292 | input GovernorInput {
293 | id: AccountID
294 | slug: String
295 | }
296 |
297 | enum GovernorKind {
298 | single
299 | multiprimary
300 | multisecondary
301 | multiother
302 | hub
303 | spoke
304 | }
305 |
306 | type GovernorMetadata {
307 | description: String
308 | }
309 |
310 | type GovernorParameters {
311 | quorumVotes: Uint256
312 | proposalThreshold: Uint256
313 | votingDelay: Uint256
314 | votingPeriod: Uint256
315 | gracePeriod: Uint256
316 | quorumNumerator: Uint256
317 | quorumDenominator: Uint256
318 | clockMode: String
319 | nomineeVettingDuration: Uint256
320 | fullWeightDuration: Uint256
321 | }
322 |
323 | enum GovernorType {
324 | governoralpha
325 | governorbravo
326 | openzeppelingovernor
327 | aave
328 | nounsfork
329 | nomineeelection
330 | memberelection
331 | hub
332 | spoke
333 | }
334 |
335 | input GovernorsFiltersInput {
336 | organizationId: IntID!
337 | includeInactive: Boolean
338 | excludeSecondary: Boolean
339 | }
340 |
341 | input GovernorsInput {
342 | filters: GovernorsFiltersInput
343 | page: PageInput
344 | sort: GovernorsSortInput
345 | }
346 |
347 | enum GovernorsSortBy {
348 | id
349 | }
350 |
351 | input GovernorsSortInput {
352 | isDescending: Boolean!
353 | sortBy: GovernorsSortBy!
354 | }
355 |
356 | # Hash: For identifying transactions on a chain. Ex: 0xDEAD.
357 | scalar Hash
358 |
359 | # HashID: A ChainID scoped identifier for identifying transactions across chains. Ex: eip155:1:0xDEAD.
360 | scalar HashID
361 |
362 | # ID: The ID scalar type represents a unique identifier
363 | scalar ID
364 |
365 | # Int: The Int scalar type represents non-fractional signed whole numeric values.
366 | scalar Int
367 |
368 | # IntID: A 64bit integer as a string - this is larger than Javascript's number.
369 | scalar IntID
370 |
371 | type Issue {
372 | id: IntID!
373 | organizationId: IntID!
374 | name: String!
375 | description: String!
376 | }
377 |
378 | type Member {
379 | id: ID!
380 | account: Account!
381 | organization: Organization!
382 | }
383 |
384 | type NativeCurrency {
385 | name: String!
386 | symbol: String!
387 | decimals: Int!
388 | }
389 |
390 | # Node: Union of all node types that are paginated.
391 | union Node =
392 | | Delegate
393 | | Organization
394 | | Member
395 | | Delegation
396 | | Governor
397 | | Proposal
398 | | Vote
399 | | StakeEvent
400 | | StakeEarning
401 | | Contributor
402 | | Allocation
403 |
404 | type Organization {
405 | id: IntID!
406 | slug: String!
407 | name: String!
408 | chainIds: [ChainID!]!
409 | tokenIds: [AssetID!]!
410 | governorIds: [AccountID!]!
411 | metadata: OrganizationMetadata
412 | creator: Account
413 | hasActiveProposals: Boolean!
414 | proposalsCount: Int!
415 | delegatesCount: Int!
416 | delegatesVotesCount: Uint256!
417 | tokenOwnersCount: Int!
418 | endorsementService: EndorsementService
419 | }
420 |
421 | input OrganizationInput {
422 | id: IntID
423 | slug: String
424 | }
425 |
426 | type OrganizationMetadata {
427 | color: String
428 | description: String
429 | icon: String
430 | socials: Socials
431 | karmaName: String
432 | }
433 |
434 | input OrganizationsFiltersInput {
435 | address: Address
436 | chainId: ChainID
437 | hasLogo: Boolean
438 | isMember: Boolean
439 | }
440 |
441 | input OrganizationsInput {
442 | filters: OrganizationsFiltersInput
443 | page: PageInput
444 | sort: OrganizationsSortInput
445 | }
446 |
447 | enum OrganizationsSortBy {
448 | id
449 | name
450 | explore
451 | popular
452 | }
453 |
454 | input OrganizationsSortInput {
455 | isDescending: Boolean!
456 | sortBy: OrganizationsSortBy!
457 | }
458 |
459 | type PageInfo {
460 | firstCursor: String
461 | lastCursor: String
462 | count: Int
463 | }
464 |
465 | input PageInput {
466 | afterCursor: String
467 | beforeCursor: String
468 | limit: Int
469 | }
470 |
471 | # PaginatedOutput: Wraps a list of nodes and the pagination info
472 | type PaginatedOutput {
473 | nodes: [Node!]!
474 | pageInfo: PageInfo!
475 | }
476 |
477 | type Parameter {
478 | name: String!
479 | type: String!
480 | value: Any!
481 | valueDecoded: [ValueDecoded!]
482 | }
483 |
484 | type Proposal {
485 | id: IntID!
486 | onchainId: String
487 | block: Block
488 | chainId: ChainID!
489 | creator: Account
490 | end: BlockOrTimestamp!
491 | events: [ProposalEvent!]!
492 | executableCalls: [ExecutableCall!]
493 | governor: Governor!
494 | metadata: ProposalMetadata!
495 | organization: Organization!
496 | proposer: Account
497 | quorum: Uint256
498 | status: ProposalStatus!
499 | start: BlockOrTimestamp!
500 | voteStats: [VoteStats!]
501 | }
502 |
503 | type ProposalEvent {
504 | block: Block!
505 | chainId: ChainID!
506 | createdAt: Timestamp!
507 | type: ProposalEventType!
508 | txHash: Hash!
509 | }
510 |
511 | enum ProposalEventType {
512 | activated
513 | canceled
514 | created
515 | defeated
516 | drafted
517 | executed
518 | expired
519 | extended
520 | pendingexecution
521 | queued
522 | succeeded
523 | callexecuted
524 | crosschainexecuted
525 | }
526 |
527 | input ProposalInput {
528 | id: IntID
529 | onchainId: String
530 | governorId: AccountID
531 | includeArchived: Boolean
532 | isLatest: Boolean
533 | }
534 |
535 | type ProposalMetadata {
536 | title: String
537 | description: String
538 | eta: Int
539 | ipfsHash: String
540 | previousEnd: Int
541 | timelockId: AccountID
542 | txHash: Hash
543 | discourseURL: String
544 | snapshotURL: String
545 | }
546 |
547 | type ProposalStats {
548 | total: Int!
549 | active: Int!
550 | failed: Int!
551 | passed: Int!
552 | }
553 |
554 | enum ProposalStatus {
555 | active
556 | archived
557 | canceled
558 | callexecuted
559 | defeated
560 | draft
561 | executed
562 | expired
563 | extended
564 | pending
565 | queued
566 | pendingexecution
567 | submitted
568 | succeeded
569 | crosschainexecuted
570 | }
571 |
572 | input ProposalsCreatedCountInput {
573 | governorId: AccountID
574 | organizationId: IntID
575 | }
576 |
577 | input ProposalsFiltersInput {
578 | governorId: AccountID
579 | includeArchived: Boolean
580 | isDraft: Boolean
581 | organizationId: IntID
582 | proposer: Address
583 | }
584 |
585 | input ProposalsInput {
586 | filters: ProposalsFiltersInput
587 | page: PageInput
588 | sort: ProposalsSortInput
589 | }
590 |
591 | enum ProposalsSortBy {
592 | id
593 | }
594 |
595 | input ProposalsSortInput {
596 | isDescending: Boolean!
597 | sortBy: ProposalsSortBy!
598 | }
599 |
600 | enum Role {
601 | ADMIN
602 | USER
603 | }
604 |
605 | type StakeEarning {
606 | amount: Uint256!
607 | date: Date!
608 | }
609 |
610 | type StakeEvent {
611 | amount: Uint256!
612 | block: Block!
613 | type: StakeEventType!
614 | }
615 |
616 | enum StakeEventType {
617 | deposit
618 | withdraw
619 | }
620 |
621 | # String: The String scalar type represents textual data, represented as UTF-8 character sequences
622 | scalar String
623 |
624 | # Timestamp: Timestamp is an RFC3339 string.
625 | scalar Timestamp
626 |
627 | type Token {
628 | id: AssetID!
629 | type: TokenType!
630 | name: String!
631 | symbol: String!
632 | supply: Uint256!
633 | decimals: Int!
634 | eligibility: Eligibility
635 | isIndexing: Boolean!
636 | isBehind: Boolean!
637 | }
638 |
639 | type TokenContract {
640 | address: Address!
641 | type: TokenType!
642 | }
643 |
644 | input TokenInput {
645 | id: AssetID!
646 | }
647 |
648 | enum TokenType {
649 | ERC20
650 | ERC721
651 | ERC20AAVE
652 | SOLANASPOKETOKEN
653 | }
654 |
655 | # Uint256: Uint256 is a large unsigned integer represented as a string.
656 | scalar Uint256
657 |
658 | type UserBio {
659 | value: String!
660 | summary: String!
661 | }
662 |
663 | type ValueDecoded {
664 | operation: Int!
665 | to: String!
666 | value: String!
667 | data: String!
668 | dataDecoded: DataDecoded
669 | }
670 |
671 | type Vote {
672 | id: IntID!
673 | amount: Uint256!
674 | block: Block!
675 | chainId: ChainID!
676 | isBridged: Boolean
677 | proposal: Proposal!
678 | reason: String
679 | type: VoteType!
680 | txHash: Hash!
681 | voter: Account!
682 | }
683 |
684 | type VoteStats {
685 | type: VoteType!
686 | votesCount: Uint256!
687 | votersCount: Int!
688 | percent: Float!
689 | }
690 |
691 | enum VoteType {
692 | abstain
693 | against
694 | for
695 | pendingabstain
696 | pendingagainst
697 | pendingfor
698 | }
699 |
700 | input VotesFiltersInput {
701 | proposalId: IntID
702 | proposalIds: [IntID!]
703 | voter: Address
704 | includePendingVotes: Boolean
705 | type: VoteType
706 | }
707 |
708 | input VotesInput {
709 | filters: VotesFiltersInput
710 | page: PageInput
711 | sort: VotesSortInput
712 | }
713 |
714 | enum VotesSortBy {
715 | id
716 | amount
717 | }
718 |
719 | input VotesSortInput {
720 | isDescending: Boolean!
721 | sortBy: VotesSortBy!
722 | }
723 |
```
--------------------------------------------------------------------------------
/src/services/tally.service.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GraphQLClient } from "graphql-request";
2 | import { getDAO } from "./organizations/getDAO.js";
3 | import { listDAOs } from "./organizations/listDAOs.js";
4 | import { listProposals } from "./proposals/listProposals.js";
5 | import { getProposal } from "./proposals/getProposal.js";
6 | import { getProposalVoters } from "./proposals/getProposalVoters.js";
7 | import { getProposalTimeline } from "./proposals/getProposalTimeline.js";
8 | import { getProposalSecurityAnalysis } from "./proposals/getProposalSecurityAnalysis.js";
9 | import { listDelegates } from "./delegates/listDelegates.js";
10 | import { getAddressProposals } from "./addresses/getAddressProposals.js";
11 | import { getAddressDAOProposals } from "./addresses/getAddressDAOProposals.js";
12 | import { getAddressVotes } from "./addresses/getAddressVotes.js";
13 | import { getAddressCreatedProposals } from "./addresses/getAddressCreatedProposals.js";
14 | import { getAddressMetadata } from "./addresses/getAddressMetadata.js";
15 | import { getAddressGovernances } from "./addresses/getAddressGovernances.js";
16 | import { getAddressReceivedDelegations } from "./addresses/getAddressReceivedDelegations.js";
17 | import { getDelegateStatement } from "./delegates/getDelegateStatement.js";
18 | import { getDelegators } from "./delegators/getDelegators.js";
19 | import type {
20 | Organization,
21 | OrganizationsResponse,
22 | ListDAOsParams,
23 | PageInfo,
24 | Token,
25 | } from "./organizations/organizations.types.js";
26 | import type { Delegate } from "./delegates/delegates.types.js";
27 | import type {
28 | Delegation,
29 | GetDelegatorsParams,
30 | TokenInfo,
31 | } from "./delegators/delegators.types.js";
32 | import type { GetAddressReceivedDelegationsInput } from "./addresses/addresses.types.js";
33 | import type { DelegateStatement } from "./delegates/delegates.types.js";
34 | import type {
35 | ProposalsInput,
36 | ProposalsResponse,
37 | ProposalInput,
38 | ProposalDetailsResponse,
39 | } from "./proposals/index.js";
40 | import type {
41 | GetProposalVotersInput,
42 | ProposalVotersResponse,
43 | } from "./proposals/getProposalVoters.types.js";
44 | import type {
45 | GetProposalTimelineInput,
46 | ProposalTimelineResponse,
47 | } from "./proposals/getProposalTimeline.types.js";
48 | import type {
49 | GetProposalSecurityAnalysisInput,
50 | ProposalSecurityAnalysisResponse,
51 | } from "./proposals/getProposalSecurityAnalysis.types.js";
52 | import type {
53 | AddressProposalsInput,
54 | AddressProposalsResponse,
55 | AddressDAOProposalsInput,
56 | AddressDAOProposalsResponse,
57 | AddressVotesInput,
58 | AddressVotesResponse,
59 | AddressCreatedProposalsInput,
60 | AddressCreatedProposalsResponse,
61 | AddressMetadataInput,
62 | AddressMetadataResponse,
63 | AddressGovernancesInput,
64 | AddressGovernancesResponse,
65 | } from "./addresses/addresses.types.js";
66 | import { getDAOTokens } from "./organizations/getDAO.js";
67 | import { getProposalVotesCast } from "./proposals/getProposalVotesCast.js";
68 | import { getProposalVotesCastList } from "./proposals/getProposalVotesCastList.js";
69 | import { getGovernanceProposalsStats } from "./proposals/getGovernanceProposalsStats.js";
70 | import type {
71 | GetProposalVotesCastInput,
72 | ProposalVotesCastResponse,
73 | } from "./proposals/getProposalVotesCast.types.js";
74 | import type {
75 | GetProposalVotesCastListInput,
76 | ProposalVotesCastListResponse,
77 | } from "./proposals/getProposalVotesCastList.types.js";
78 | import type { GovernanceProposalsStatsResponse } from "./proposals/proposals.types.js";
79 | import type { ListProposalsParams } from "./proposals/listProposals.types.js";
80 | import type { ListDelegatesParams } from "./delegates/delegates.types.js";
81 |
82 | export interface TallyServiceConfig {
83 | apiKey: string;
84 | baseUrl?: string;
85 | }
86 |
87 | export interface GetAddressReceivedDelegationsOutput {
88 | nodes: Array<{
89 | id: string;
90 | votes: string;
91 | delegator: {
92 | id: string;
93 | address: string;
94 | };
95 | }>;
96 | pageInfo: {
97 | firstCursor: string | null;
98 | lastCursor: string | null;
99 | count: number;
100 | };
101 | totalCount: number;
102 | }
103 |
104 | export type GetDelegateStatementInput = {
105 | address: string;
106 | } & (
107 | | { governorId: string; organizationSlug?: never }
108 | | { organizationSlug: string; governorId?: never }
109 | );
110 |
111 | export class TallyService {
112 | private client: GraphQLClient;
113 |
114 | constructor(config: TallyServiceConfig) {
115 | this.client = new GraphQLClient(
116 | config.baseUrl || "https://api.tally.xyz/query",
117 | {
118 | headers: {
119 | "Content-Type": "application/json",
120 | "api-key": config.apiKey,
121 | },
122 | }
123 | );
124 | }
125 |
126 | async listProposals(params: ListProposalsParams): Promise<ProposalsResponse> {
127 | return listProposals(this.client, params);
128 | }
129 |
130 | async getDAO(slug: string): Promise<Organization> {
131 | const { organization } = await getDAO(this.client, slug);
132 | return {
133 | id: organization.id,
134 | name: organization.name,
135 | slug: organization.slug,
136 | chainIds: organization.chainIds,
137 | tokenIds: organization.tokenIds,
138 | governorIds: organization.governorIds,
139 | tokenOwnersCount: organization.tokenOwnersCount,
140 | delegatesCount: organization.delegatesCount,
141 | proposalsCount: organization.proposalsCount,
142 | hasActiveProposals: organization.hasActiveProposals,
143 | metadata: organization.metadata,
144 | delegatesVotesCount: organization.delegatesVotesCount || 0,
145 | };
146 | }
147 |
148 | async getDAOTokens(tokenIds: string[]): Promise<Token[]> {
149 | return getDAOTokens(this.client, tokenIds);
150 | }
151 |
152 | async listDAOs(params: ListDAOsParams = {}): Promise<OrganizationsResponse> {
153 | return listDAOs(this.client, params);
154 | }
155 |
156 | async listDelegates(input: ListDelegatesParams) {
157 | if (!input.organizationSlug) {
158 | throw new Error("organizationSlug must be a string");
159 | }
160 | return listDelegates(this.client, input);
161 | }
162 |
163 | async getProposal(input: ProposalInput): Promise<ProposalDetailsResponse> {
164 | return getProposal(this.client, input);
165 | }
166 |
167 | async getProposalVoters(
168 | input: GetProposalVotersInput
169 | ): Promise<ProposalVotersResponse> {
170 | if (!input.proposalId) {
171 | throw new Error("proposalId is required");
172 | }
173 | return getProposalVoters(this.client, input);
174 | }
175 |
176 | async getProposalTimeline(
177 | input: GetProposalTimelineInput
178 | ): Promise<ProposalTimelineResponse> {
179 | if (!input.proposalId) {
180 | throw new Error("proposalId is required");
181 | }
182 | return getProposalTimeline(this.client, input);
183 | }
184 |
185 | async getProposalSecurityAnalysis(
186 | input: GetProposalSecurityAnalysisInput
187 | ): Promise<ProposalSecurityAnalysisResponse> {
188 | if (!input.proposalId) {
189 | throw new Error("proposalId is required");
190 | }
191 | return getProposalSecurityAnalysis(this.client, input);
192 | }
193 |
194 | async getAddressProposals(
195 | input: AddressProposalsInput
196 | ): Promise<AddressProposalsResponse> {
197 | if (!input.address) {
198 | throw new Error("address is required");
199 | }
200 | return getAddressProposals(this.client, input);
201 | }
202 |
203 | async getAddressDAOProposals(
204 | input: AddressDAOProposalsInput
205 | ): Promise<AddressDAOProposalsResponse> {
206 | if (!input.address) {
207 | throw new Error("Address is required");
208 | }
209 | const response = await getAddressDAOProposals(this.client, input);
210 | return {
211 | proposals: {
212 | nodes: response.proposals?.nodes || [],
213 | pageInfo: response.proposals?.pageInfo || {
214 | firstCursor: null,
215 | lastCursor: null,
216 | },
217 | },
218 | };
219 | }
220 |
221 | async getAddressVotes(
222 | input: AddressVotesInput
223 | ): Promise<AddressVotesResponse> {
224 | return getAddressVotes(this.client, input);
225 | }
226 |
227 | async getAddressCreatedProposals(
228 | input: AddressCreatedProposalsInput
229 | ): Promise<AddressCreatedProposalsResponse> {
230 | if (!input.address) {
231 | throw new Error("address is required");
232 | }
233 | const response = await getAddressCreatedProposals(this.client, input);
234 | return {
235 | proposals: {
236 | nodes: response.proposals?.nodes || [],
237 | pageInfo: response.proposals?.pageInfo || {
238 | firstCursor: null,
239 | lastCursor: null,
240 | },
241 | },
242 | };
243 | }
244 |
245 | async getAddressMetadata(
246 | input: AddressMetadataInput
247 | ): Promise<AddressMetadataResponse> {
248 | if (!input.address) {
249 | throw new Error("Address is required");
250 | }
251 | const response = await getAddressMetadata(this.client, input);
252 | return {
253 | address: response.address?.address || input.address,
254 | accounts: response.address?.accounts || [],
255 | };
256 | }
257 |
258 | async getAddressGovernances(
259 | input: AddressGovernancesInput
260 | ): Promise<Record<string, any>> {
261 | return await getAddressGovernances(this.client, input);
262 | }
263 |
264 | async getAddressReceivedDelegations(
265 | input: GetAddressReceivedDelegationsInput
266 | ): Promise<GetAddressReceivedDelegationsOutput> {
267 | if (!input.address) {
268 | throw new Error("address is required");
269 | }
270 | return getAddressReceivedDelegations(this.client, input);
271 | }
272 |
273 | async getDelegateStatement(
274 | input: GetDelegateStatementInput
275 | ): Promise<DelegateStatement | null> {
276 | const response = await getDelegateStatement(this.client, input);
277 | if (!response?.statement) return null;
278 |
279 | return {
280 | id: response.statement.id,
281 | address: response.statement.address,
282 | statement: response.statement.statement,
283 | statementSummary: response.statement.statementSummary || "",
284 | isSeekingDelegation: response.statement.isSeekingDelegation || false,
285 | issues: response.statement.issues || [],
286 | };
287 | }
288 |
289 | async getDelegators(params: GetDelegatorsParams): Promise<{
290 | delegators: Delegation[];
291 | pageInfo: PageInfo;
292 | }> {
293 | if (!params.address) {
294 | throw new Error("address is required");
295 | }
296 | return getDelegators(this.client, params);
297 | }
298 |
299 | async getProposalVotesCast(
300 | input: GetProposalVotesCastInput
301 | ): Promise<ProposalVotesCastResponse> {
302 | if (!input.id) {
303 | throw new Error("proposalId is required");
304 | }
305 | return getProposalVotesCast(this.client, input);
306 | }
307 |
308 | async getProposalVotesCastList(
309 | input: GetProposalVotesCastListInput
310 | ): Promise<ProposalVotesCastListResponse> {
311 | return getProposalVotesCastList(this.client, input);
312 | }
313 |
314 | async getGovernanceProposalsStats(input: {
315 | slug: string;
316 | }): Promise<GovernanceProposalsStatsResponse> {
317 | return getGovernanceProposalsStats(this.client, input);
318 | }
319 |
320 | /**
321 | * Format a vote amount considering token decimals
322 | * @param {string} votes - The raw vote amount
323 | * @param {TokenInfo} token - Optional token info containing decimals and symbol
324 | * @returns {string} Formatted vote amount with optional symbol
325 | */
326 | private static formatVotes(votes: string, token?: TokenInfo): string {
327 | const val = BigInt(votes);
328 | const decimals = token?.decimals ?? 18;
329 | const denominator = BigInt(10 ** decimals);
330 | const formatted = (Number(val) / Number(denominator)).toLocaleString();
331 | return `${formatted}${token?.symbol ? ` ${token.symbol}` : ""}`;
332 | }
333 |
334 | static formatDAOList(daos: Organization[]): string {
335 | return (
336 | `Found ${daos.length} DAOs:\n\n` +
337 | daos
338 | .map(
339 | (dao) =>
340 | `${dao.name} (${dao.slug})\n` +
341 | `Token Holders: ${dao.tokenOwnersCount}\n` +
342 | `Delegates: ${dao.delegatesCount}\n` +
343 | `Proposals: ${dao.proposalsCount}\n` +
344 | `Active Proposals: ${dao.hasActiveProposals ? "Yes" : "No"}\n` +
345 | `Description: ${
346 | dao.metadata?.description || "No description available"
347 | }\n` +
348 | `Website: ${dao.metadata?.socials?.website || "N/A"}\n` +
349 | `Twitter: ${dao.metadata?.socials?.twitter || "N/A"}\n` +
350 | `Discord: ${dao.metadata?.socials?.discord || "N/A"}\n` +
351 | "---"
352 | )
353 | .join("\n\n")
354 | );
355 | }
356 |
357 | static formatDAO(dao: Organization): string {
358 | return (
359 | `${dao.name} (${dao.slug})\n` +
360 | `Token Holders: ${dao.tokenOwnersCount}\n` +
361 | `Delegates: ${dao.delegatesCount}\n` +
362 | `Proposals: ${dao.proposalsCount}\n` +
363 | `Active Proposals: ${dao.hasActiveProposals ? "Yes" : "No"}\n` +
364 | `Description: ${
365 | dao.metadata?.description || "No description available"
366 | }\n` +
367 | `Website: ${dao.metadata?.socials?.website || "N/A"}\n` +
368 | `Twitter: ${dao.metadata?.socials?.twitter || "N/A"}\n` +
369 | `Discord: ${dao.metadata?.socials?.discord || "N/A"}\n` +
370 | `Chain IDs: ${dao.chainIds.join(", ")}\n` +
371 | `Token IDs: ${dao.tokenIds?.join(", ") || "N/A"}\n` +
372 | `Governor IDs: ${dao.governorIds?.join(", ") || "N/A"}`
373 | );
374 | }
375 |
376 | static formatDelegatesList(delegates: Delegate[]): string {
377 | return (
378 | `Found ${delegates.length} delegates:\n\n` +
379 | delegates
380 | .map(
381 | (delegate) =>
382 | `${delegate.account.name || delegate.account.address}\n` +
383 | `Address: ${delegate.account.address}\n` +
384 | `Votes: ${delegate.votesCount}\n` +
385 | `Delegators: ${delegate.delegatorsCount}\n` +
386 | `Bio: ${delegate.account.bio || "No bio available"}\n` +
387 | `Statement: ${
388 | delegate.statement?.statementSummary || "No statement available"
389 | }\n` +
390 | "---"
391 | )
392 | .join("\n\n")
393 | );
394 | }
395 |
396 | static formatDelegatorsList(delegators: Delegation[]): string {
397 | return (
398 | `Found ${delegators.length} delegators:\n\n` +
399 | delegators
400 | .map(
401 | (delegation) =>
402 | `${
403 | delegation.delegator.name ||
404 | delegation.delegator.ens ||
405 | delegation.delegator.address
406 | }\n` +
407 | `Address: ${delegation.delegator.address}\n` +
408 | `Votes: ${TallyService.formatVotes(
409 | delegation.votes,
410 | delegation.token
411 | )}\n` +
412 | `Delegated at: Block ${delegation.blockNumber} (${new Date(
413 | delegation.blockTimestamp
414 | ).toLocaleString()})\n` +
415 | `${
416 | delegation.token
417 | ? `Token: ${delegation.token.symbol} (${delegation.token.name})\n`
418 | : ""
419 | }` +
420 | "---"
421 | )
422 | .join("\n\n")
423 | );
424 | }
425 |
426 | static formatProposal(proposal: any): string {
427 | return `Proposal: ${proposal.metadata.title}
428 | ID: ${proposal.id}
429 | Status: ${proposal.status}
430 | Created: ${new Date(proposal.createdAt).toLocaleString()}
431 | Description: ${proposal.metadata.description}
432 | Governor: ${proposal.governor.name}
433 | Vote Stats:
434 | ${proposal.voteStats
435 | .map(
436 | (stat: any) =>
437 | ` ${stat.type}: ${stat.percent.toFixed(2)}% (${
438 | stat.votesCount
439 | } votes from ${stat.votersCount} voters)`
440 | )
441 | .join("\n")}`;
442 | }
443 |
444 | static formatProposalsList(proposals: any[]): string {
445 | return (
446 | `Found ${proposals.length} proposals:\n\n` +
447 | proposals
448 | .map(
449 | (proposal) =>
450 | `${proposal.metadata.title}\n` +
451 | `Tally ID: ${proposal.id}\n` +
452 | `Status: ${proposal.status}\n` +
453 | `Created: ${new Date(proposal.createdAt).toLocaleString()}\n\n`
454 | )
455 | .join("")
456 | );
457 | }
458 | }
459 |
```
--------------------------------------------------------------------------------
/src/tools.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { type Tool } from "@modelcontextprotocol/sdk/types.js";
2 | import { type TextContent } from "@modelcontextprotocol/sdk/types.js";
3 | import { TallyService } from "./services/tally.service.js";
4 |
5 | export const tools: Tool[] = [
6 | {
7 | name: "list-daos",
8 | description: "List DAOs on Tally sorted by specified criteria",
9 | inputSchema: {
10 | type: "object",
11 | properties: {
12 | limit: {
13 | type: "number",
14 | description:
15 | "Maximum number of DAOs to return (default: 20, max: 50)",
16 | },
17 | afterCursor: {
18 | type: "string",
19 | description: "Cursor for pagination",
20 | },
21 | sortBy: {
22 | type: "string",
23 | enum: ["id", "name", "explore", "popular"],
24 | description:
25 | "How to sort the DAOs (default: popular). 'explore' prioritizes DAOs with live proposals",
26 | },
27 | },
28 | },
29 | },
30 | {
31 | name: "get-dao",
32 | description: "Get detailed information about a specific DAO",
33 | inputSchema: {
34 | type: "object",
35 | required: ["slug"],
36 | properties: {
37 | slug: {
38 | type: "string",
39 | description: "The DAO's slug (e.g., 'uniswap' or 'aave')",
40 | },
41 | },
42 | },
43 | },
44 | {
45 | name: "list-delegates",
46 | description:
47 | "List delegates for a specific organization with their metadata",
48 | inputSchema: {
49 | type: "object",
50 | required: ["organizationSlug"],
51 | properties: {
52 | organizationSlug: {
53 | type: "string",
54 | description:
55 | "The organization's slug (e.g., 'arbitrum')",
56 | },
57 | limit: {
58 | type: "number",
59 | description:
60 | "Maximum number of delegates to return (default: 20, max: 50)",
61 | },
62 | afterCursor: {
63 | type: "string",
64 | description: "Cursor for pagination",
65 | },
66 | hasVotes: {
67 | type: "boolean",
68 | description: "Filter for delegates with votes",
69 | },
70 | hasDelegators: {
71 | type: "boolean",
72 | description: "Filter for delegates with delegators",
73 | },
74 | isSeekingDelegation: {
75 | type: "boolean",
76 | description: "Filter for delegates seeking delegation",
77 | },
78 | },
79 | },
80 | },
81 | {
82 | name: "get-delegators",
83 | description: "Get list of delegators for a specific address",
84 | inputSchema: {
85 | type: "object",
86 | required: ["address", "organizationSlug"],
87 | properties: {
88 | address: {
89 | type: "string",
90 | description: "The Ethereum address to get delegators for (0x format)",
91 | },
92 | organizationSlug: {
93 | type: "string",
94 | description:
95 | "Filter by organization slug (e.g., 'uniswap'). Alternative to organizationId",
96 | },
97 | governorId: {
98 | type: "string",
99 | description: "Filter by specific governor ID",
100 | },
101 | limit: {
102 | type: "number",
103 | description:
104 | "Maximum number of delegators to return (default: 20, max: 50)",
105 | },
106 | afterCursor: {
107 | type: "string",
108 | description: "Cursor for pagination",
109 | },
110 | beforeCursor: {
111 | type: "string",
112 | description: "Cursor for previous page pagination",
113 | },
114 | sortBy: {
115 | type: "string",
116 | enum: ["id", "votes"],
117 | description: "How to sort the delegators (default: id)",
118 | },
119 | isDescending: {
120 | type: "boolean",
121 | description: "Sort in descending order (default: true)",
122 | },
123 | },
124 | },
125 | },
126 | {
127 | name: "list-proposals",
128 | description: "List proposals for a specific DAO or organization using its slug",
129 | inputSchema: {
130 | type: "object",
131 | properties: {
132 | slug: {
133 | type: "string",
134 | description: "The slug of the DAO (e.g., 'uniswap')",
135 | },
136 | includeArchived: {
137 | type: "boolean",
138 | description: "Include archived proposals",
139 | },
140 | isDraft: {
141 | type: "boolean",
142 | description: "Filter for draft proposals",
143 | },
144 | limit: {
145 | type: "number",
146 | description: "Maximum number of proposals to return (default: 50, max: 50)",
147 | },
148 | afterCursor: {
149 | type: "string",
150 | description: "Cursor for pagination (string ID)",
151 | },
152 | beforeCursor: {
153 | type: "string",
154 | description: "Cursor for previous page pagination (string ID)",
155 | },
156 | isDescending: {
157 | type: "boolean",
158 | description: "Sort in descending order (default: true)",
159 | }
160 | },
161 | required: ["slug"]
162 | }
163 | },
164 | {
165 | name: "get-proposal",
166 | description:
167 | "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).",
168 | inputSchema: {
169 | type: "object",
170 | oneOf: [
171 | {
172 | required: ["id"],
173 | properties: {
174 | id: {
175 | type: "string",
176 | description:
177 | "The proposal's Tally ID (globally unique across all governors)",
178 | },
179 | includeArchived: {
180 | type: "boolean",
181 | description: "Include archived proposals",
182 | },
183 | isLatest: {
184 | type: "boolean",
185 | description: "Get the latest version of the proposal",
186 | },
187 | },
188 | },
189 | {
190 | required: ["onchainId", "governorId"],
191 | properties: {
192 | onchainId: {
193 | type: "string",
194 | description:
195 | "The proposal's onchain ID (only unique within a governor)",
196 | },
197 | governorId: {
198 | type: "string",
199 | description: "The governor's ID (required when using onchainId)",
200 | },
201 | includeArchived: {
202 | type: "boolean",
203 | description: "Include archived proposals",
204 | },
205 | isLatest: {
206 | type: "boolean",
207 | description: "Get the latest version of the proposal",
208 | },
209 | },
210 | },
211 | ],
212 | },
213 | },
214 | {
215 | name: "get-address-votes",
216 | description: "Get votes cast by an address for a specific organization",
217 | inputSchema: {
218 | type: "object",
219 | required: ["address", "organizationSlug"],
220 | properties: {
221 | address: {
222 | type: "string",
223 | description: "The address to get votes for",
224 | },
225 | organizationSlug: {
226 | type: "string",
227 | description: "The organization slug to get votes from",
228 | },
229 | limit: {
230 | type: "number",
231 | description: "Maximum number of votes to return (default: 20)",
232 | },
233 | afterCursor: {
234 | type: "string",
235 | description: "Cursor for pagination",
236 | },
237 | },
238 | },
239 | },
240 | {
241 | name: "get-address-created-proposals",
242 | description:
243 | "Get proposals created by an address for a specific organization",
244 | inputSchema: {
245 | type: "object",
246 | required: ["address", "organizationSlug"],
247 | properties: {
248 | address: {
249 | type: "string",
250 | description: "The Ethereum address to get created proposals for",
251 | },
252 | organizationSlug: {
253 | type: "string",
254 | description: "The organization slug to get proposals from",
255 | },
256 | limit: {
257 | type: "number",
258 | description: "Maximum number of proposals to return (default: 20)",
259 | },
260 | afterCursor: {
261 | type: "string",
262 | description: "Cursor for pagination",
263 | },
264 | beforeCursor: {
265 | type: "string",
266 | description: "Cursor for previous page pagination",
267 | },
268 | },
269 | },
270 | handler: async function (
271 | this: { service: TallyService },
272 | input: Record<string, unknown>
273 | ) {
274 | const { address, organizationSlug } = input;
275 | if (typeof address !== "string") {
276 | throw new Error("address must be a string");
277 | }
278 | if (typeof organizationSlug !== "string") {
279 | throw new Error("organizationSlug must be a string");
280 | }
281 | const result = await (this.service as any).getAddressCreatedProposals({
282 | address,
283 | organizationSlug,
284 | limit: typeof input.limit === "number" ? input.limit : undefined,
285 | afterCursor:
286 | typeof input.afterCursor === "string" ? input.afterCursor : undefined,
287 | beforeCursor:
288 | typeof input.beforeCursor === "string"
289 | ? input.beforeCursor
290 | : undefined,
291 | });
292 | return JSON.stringify(result);
293 | },
294 | },
295 | {
296 | name: "get-address-daos-proposals",
297 | description:
298 | "Returns proposals from DAOs where a given address has participated (voted, proposed, etc.)",
299 | inputSchema: {
300 | type: "object",
301 | required: ["address", "organizationSlug"],
302 | properties: {
303 | address: {
304 | type: "string",
305 | description: "The Ethereum address",
306 | },
307 | organizationSlug: {
308 | type: "string",
309 | description: "The organization slug to get proposals from",
310 | },
311 | limit: {
312 | type: "number",
313 | description:
314 | "Maximum number of proposals to return (default: 20, max: 50)",
315 | },
316 | afterCursor: {
317 | type: "string",
318 | description: "Cursor for pagination",
319 | },
320 | },
321 | },
322 | },
323 | {
324 | name: "get-address-received-delegations",
325 | description: "Returns delegations received by an address",
326 | inputSchema: {
327 | type: "object",
328 | required: ["address", "organizationSlug"],
329 | properties: {
330 | address: {
331 | type: "string",
332 | description:
333 | "The Ethereum address to get received delegations for (0x format)",
334 | },
335 | organizationSlug: {
336 | type: "string",
337 | description: "Filter by organization slug",
338 | },
339 | limit: {
340 | type: "number",
341 | description:
342 | "Maximum number of delegations to return (default: 20, max: 50)",
343 | },
344 | sortBy: {
345 | type: "string",
346 | enum: ["votes"],
347 | description: "Field to sort by",
348 | },
349 | isDescending: {
350 | type: "boolean",
351 | description: "Sort in descending order",
352 | },
353 | },
354 | },
355 | },
356 | {
357 | name: "get-delegate-statement",
358 | description:
359 | "Get a delegate's statement for a specific governor or organization",
360 | inputSchema: {
361 | type: "object",
362 | required: ["address"],
363 | oneOf: [
364 | {
365 | required: ["governorId"],
366 | properties: {
367 | address: {
368 | type: "string",
369 | description: "The delegate's Ethereum address",
370 | },
371 | governorId: {
372 | type: "string",
373 | description: "The governor's ID",
374 | },
375 | },
376 | },
377 | {
378 | required: ["organizationSlug"],
379 | properties: {
380 | address: {
381 | type: "string",
382 | description: "The delegate's Ethereum address",
383 | },
384 | organizationSlug: {
385 | type: "string",
386 | description: "The organization's slug (e.g., 'uniswap')",
387 | },
388 | },
389 | },
390 | ],
391 | },
392 | },
393 | {
394 | name: "get-address-governances",
395 | description:
396 | "Returns the list of governances (DAOs) an address has delegated to",
397 | inputSchema: {
398 | type: "object",
399 | required: ["address"],
400 | properties: {
401 | address: {
402 | type: "string",
403 | description:
404 | "The Ethereum address to get governances for (0x format)",
405 | },
406 | },
407 | },
408 | },
409 | {
410 | name: "get-proposal-timeline",
411 | description: "Get the timeline of events for a specific proposal",
412 | inputSchema: {
413 | type: "object",
414 | required: ["proposalId"],
415 | properties: {
416 | proposalId: {
417 | type: "string",
418 | description: "The ID of the proposal to get the timeline for",
419 | },
420 | },
421 | },
422 | handler: async function (
423 | this: { service: TallyService },
424 | input: Record<string, unknown>
425 | ) {
426 | if (typeof input.proposalId !== "string") {
427 | throw new Error("proposalId must be a string");
428 | }
429 | const result = await this.service.getProposalTimeline({
430 | proposalId: input.proposalId,
431 | });
432 | const content: TextContent[] = [
433 | {
434 | type: "text",
435 | text: JSON.stringify(result),
436 | },
437 | ];
438 | return { content };
439 | },
440 | },
441 | {
442 | name: "get-proposal-voters",
443 | description:
444 | "Get a list of all voters who have voted on a specific proposal",
445 | inputSchema: {
446 | type: "object",
447 | required: ["proposalId"],
448 | properties: {
449 | proposalId: {
450 | type: "string",
451 | description: "The ID of the proposal to get voters for",
452 | },
453 | limit: {
454 | type: "number",
455 | description: "Maximum number of voters to return (default: 20)",
456 | },
457 | afterCursor: {
458 | type: "string",
459 | description: "Cursor for pagination",
460 | },
461 | beforeCursor: {
462 | type: "string",
463 | description: "Cursor for previous page pagination",
464 | },
465 | sortBy: {
466 | type: "string",
467 | enum: ["id", "amount"],
468 | description: "How to sort the voters ('id' sorts by date (default), 'amount' sorts by voting power)",
469 | },
470 | isDescending: {
471 | type: "boolean",
472 | description: "Sort in descending order (true shows most recent/largest first)",
473 | },
474 | },
475 | },
476 | },
477 | {
478 | name: "get-address-metadata",
479 | description: "Get metadata information about a specific Ethereum address",
480 | inputSchema: {
481 | type: "object",
482 | required: ["address"],
483 | properties: {
484 | address: {
485 | type: "string",
486 | description: "The Ethereum address to get metadata for (0x format)",
487 | },
488 | },
489 | },
490 | },
491 | {
492 | name: "get-proposal-security-analysis",
493 | description:
494 | "Get security analysis for a specific proposal, including threat analysis and simulations",
495 | inputSchema: {
496 | type: "object",
497 | required: ["proposalId"],
498 | properties: {
499 | proposalId: {
500 | type: "string",
501 | description: "The ID of the proposal to get security analysis for",
502 | },
503 | },
504 | },
505 | },
506 | {
507 | name: "get-proposal-votes-cast",
508 | description:
509 | "Get vote statistics and formatted vote counts for a specific proposal",
510 | inputSchema: {
511 | type: "object",
512 | required: ["id"],
513 | properties: {
514 | id: {
515 | type: "string",
516 | description: "The proposal's ID",
517 | },
518 | },
519 | },
520 | },
521 | {
522 | name: "get-proposal-votes-cast-list",
523 | description:
524 | "Get a list of votes cast for a specific proposal, including formatted vote amounts",
525 | inputSchema: {
526 | type: "object",
527 | required: ["id"],
528 | properties: {
529 | id: {
530 | type: "string",
531 | description:
532 | "The proposal's Tally ID (globally unique across all governors)",
533 | },
534 | },
535 | },
536 | },
537 | {
538 | name: "get-governance-proposals-stats",
539 | description: "Get statistics about passed and failed proposals for a specific DAO",
540 | inputSchema: {
541 | type: "object",
542 | required: ["slug"],
543 | properties: {
544 | slug: {
545 | type: "string",
546 | description: "The DAO's slug (e.g., 'uniswap' or 'aave')",
547 | },
548 | },
549 | },
550 | },
551 | ];
552 |
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import {
4 | ListToolsRequestSchema,
5 | CallToolRequestSchema,
6 | type Tool,
7 | type TextContent,
8 | } from "@modelcontextprotocol/sdk/types.js";
9 | import { TallyService } from "./services/tally.service.js";
10 | import type { OrganizationsSortBy } from "./services/organizations/organizations.types.js";
11 | import { tools } from "./tools.js";
12 |
13 | export class TallyServer {
14 | private server: Server;
15 | private service: TallyService;
16 |
17 | constructor(apiKey: string) {
18 | // Initialize service
19 | this.service = new TallyService({ apiKey });
20 |
21 | // Create server instance
22 | this.server = new Server(
23 | {
24 | name: "tally-api",
25 | version: "1.0.0",
26 | },
27 | {
28 | capabilities: {
29 | tools: {},
30 | },
31 | }
32 | );
33 |
34 | this.setupHandlers();
35 | }
36 |
37 | private setupHandlers() {
38 | // List available tools
39 | this.server.setRequestHandler(ListToolsRequestSchema, async () => {
40 | return { tools };
41 | });
42 |
43 | // Handle tool execution
44 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
45 | const { name, arguments: args = {} } = request.params;
46 |
47 | if (name === "list-daos") {
48 | try {
49 | const data = await this.service.listDAOs({
50 | limit: typeof args.limit === "number" ? args.limit : undefined,
51 | afterCursor:
52 | typeof args.afterCursor === "string"
53 | ? args.afterCursor
54 | : undefined,
55 | sortBy:
56 | typeof args.sortBy === "string"
57 | ? (args.sortBy as OrganizationsSortBy)
58 | : undefined,
59 | });
60 |
61 | const content: TextContent[] = [
62 | {
63 | type: "text",
64 | text: JSON.stringify(data, null, 2),
65 | },
66 | ];
67 |
68 | return { content };
69 | } catch (error) {
70 | throw new Error(
71 | `Error fetching DAOs: ${
72 | error instanceof Error ? error.message : "Unknown error"
73 | }`
74 | );
75 | }
76 | }
77 |
78 | if (name === "get-dao") {
79 | try {
80 | if (typeof args.slug !== "string") {
81 | throw new Error("slug must be a string");
82 | }
83 |
84 | const result = await this.service.getDAO(args.slug);
85 |
86 | const content: TextContent[] = [
87 | {
88 | type: "text",
89 | text: JSON.stringify(result, null, 2),
90 | },
91 | ];
92 |
93 | return { content };
94 | } catch (error) {
95 | throw new Error(
96 | `Error getting DAO: ${error instanceof Error ? error.message : "Unknown error"}`
97 | );
98 | }
99 | }
100 |
101 | if (name === "list-delegates") {
102 | try {
103 | if (typeof args.organizationSlug !== "string") {
104 | throw new Error("organizationSlug must be a string");
105 | }
106 |
107 | const result = await this.service.listDelegates({
108 | organizationSlug: args.organizationSlug as string,
109 | limit: typeof args.limit === "number" ? args.limit : undefined,
110 | afterCursor: typeof args.afterCursor === "string" ? args.afterCursor : undefined,
111 | beforeCursor: typeof args.beforeCursor === "string" ? args.beforeCursor : undefined,
112 | hasVotes: typeof args.hasVotes === "boolean" ? args.hasVotes : undefined,
113 | hasDelegators: typeof args.hasDelegators === "boolean" ? args.hasDelegators : undefined,
114 | isSeekingDelegation: typeof args.isSeekingDelegation === "boolean" ? args.isSeekingDelegation : undefined
115 | });
116 |
117 | const content: TextContent[] = [
118 | {
119 | type: "text",
120 | text: JSON.stringify(result, null, 2),
121 | },
122 | ];
123 |
124 | return { content };
125 | } catch (error) {
126 | throw new Error(
127 | `Error listing delegates: ${error instanceof Error ? error.message : "Unknown error"}`
128 | );
129 | }
130 | }
131 |
132 | if (name === "get-delegators") {
133 | try {
134 | if (typeof args.address !== "string") {
135 | throw new Error("address must be a string");
136 | }
137 |
138 | const organizationId = typeof args.organizationId === "string" ? args.organizationId : undefined;
139 | const organizationSlug = typeof args.organizationSlug === "string" ? args.organizationSlug : undefined;
140 | const governorId = typeof args.governorId === "string" ? args.governorId : undefined;
141 | const limit = typeof args.limit === "number" ? args.limit : 20;
142 | const afterCursor = typeof args.afterCursor === "string" ? args.afterCursor : undefined;
143 | const beforeCursor = typeof args.beforeCursor === "string" ? args.beforeCursor : undefined;
144 | const sortBy = typeof args.sortBy === "string" && (args.sortBy === "votes" || args.sortBy === "id") ? args.sortBy : undefined;
145 | const isDescending = typeof args.isDescending === "boolean" ? args.isDescending : undefined;
146 |
147 | const result = await this.service.getDelegators({
148 | address: args.address,
149 | organizationId,
150 | organizationSlug,
151 | governorId,
152 | limit,
153 | afterCursor,
154 | beforeCursor,
155 | sortBy,
156 | isDescending,
157 | });
158 |
159 | const content: TextContent[] = [
160 | {
161 | type: "text",
162 | text: JSON.stringify(result, null, 2),
163 | },
164 | ];
165 |
166 | return { content };
167 | } catch (error) {
168 | throw new Error(
169 | `Error getting delegators: ${error instanceof Error ? error.message : "Unknown error"}`
170 | );
171 | }
172 | }
173 |
174 | if (name === "list-proposals") {
175 | try {
176 | if (typeof args.slug !== "string") {
177 | throw new Error("slug must be a string");
178 | }
179 |
180 | const data = await this.service.listProposals({
181 | slug: args.slug,
182 | includeArchived: typeof args.includeArchived === "boolean" ? args.includeArchived : undefined,
183 | isDraft: typeof args.isDraft === "boolean" ? args.isDraft : undefined,
184 | limit: typeof args.limit === "number" ? args.limit : undefined,
185 | afterCursor: typeof args.afterCursor === "string" ? args.afterCursor : undefined,
186 | beforeCursor: typeof args.beforeCursor === "string" ? args.beforeCursor : undefined,
187 | isDescending: typeof args.isDescending === "boolean" ? args.isDescending : undefined
188 | });
189 |
190 | const content: TextContent[] = [
191 | {
192 | type: "text",
193 | text: JSON.stringify(data, null, 2),
194 | },
195 | ];
196 |
197 | return { content };
198 | } catch (error) {
199 | throw new Error(
200 | `Error fetching proposals: ${
201 | error instanceof Error ? error.message : "Unknown error"
202 | }`
203 | );
204 | }
205 | }
206 |
207 | if (name === "get-proposal") {
208 | try {
209 | const id = typeof args.id === "string" ? args.id : undefined;
210 | const onchainId = typeof args.onchainId === "string" ? args.onchainId : undefined;
211 | const governorId = typeof args.governorId === "string" ? args.governorId : undefined;
212 | const includeArchived = typeof args.includeArchived === "boolean" ? args.includeArchived : undefined;
213 | const isLatest = typeof args.isLatest === "boolean" ? args.isLatest : undefined;
214 |
215 | if (!id && (!onchainId || !governorId)) {
216 | throw new Error("Must provide either id or both onchainId and governorId");
217 | }
218 |
219 | const result = await this.service.getProposal({
220 | id,
221 | onchainId,
222 | governorId,
223 | includeArchived,
224 | isLatest,
225 | });
226 |
227 | const content: TextContent[] = [
228 | {
229 | type: "text",
230 | text: JSON.stringify(result, null, 2),
231 | },
232 | ];
233 |
234 | return { content };
235 | } catch (error) {
236 | throw new Error(
237 | `Error getting proposal: ${error instanceof Error ? error.message : "Unknown error"}`
238 | );
239 | }
240 | }
241 |
242 | if (name === "get-address-created-proposals") {
243 | try {
244 | if (typeof args.address !== "string") {
245 | throw new Error("address must be a string");
246 | }
247 | if (typeof args.organizationSlug !== "string") {
248 | throw new Error("organizationSlug must be a string");
249 | }
250 |
251 | const result = await (this.service as any).getAddressCreatedProposals({
252 | address: args.address,
253 | organizationSlug: args.organizationSlug,
254 | limit: typeof args.limit === "number" ? args.limit : undefined,
255 | afterCursor: typeof args.afterCursor === "string" ? args.afterCursor : undefined,
256 | beforeCursor: typeof args.beforeCursor === "string" ? args.beforeCursor : undefined
257 | });
258 |
259 | const content: TextContent[] = [
260 | {
261 | type: "text",
262 | text: JSON.stringify(result, null, 2),
263 | },
264 | ];
265 |
266 | return { content };
267 | } catch (error) {
268 | throw new Error(
269 | `Error fetching address created proposals: ${
270 | error instanceof Error ? error.message : "Unknown error"
271 | }`
272 | );
273 | }
274 | }
275 |
276 | if (name === "get-address-daos-proposals") {
277 | try {
278 | if (typeof args.address !== "string") {
279 | throw new Error("address must be a string");
280 | }
281 | if (typeof args.organizationSlug !== "string") {
282 | throw new Error("organizationSlug must be a string");
283 | }
284 |
285 | const result = await this.service.getAddressDAOProposals({
286 | address: args.address,
287 | organizationSlug: args.organizationSlug,
288 | limit: typeof args.limit === "number" ? args.limit : undefined,
289 | afterCursor: typeof args.afterCursor === "string" ? args.afterCursor : undefined,
290 | });
291 |
292 | const content: TextContent[] = [
293 | {
294 | type: "text",
295 | text: JSON.stringify(result, null, 2)
296 | }
297 | ];
298 |
299 | return { content };
300 | } catch (error) {
301 | throw new Error(
302 | `Error fetching address DAO proposals: ${
303 | error instanceof Error ? error.message : "Unknown error"
304 | }`
305 | );
306 | }
307 | }
308 |
309 | if (name === "get-address-votes") {
310 | try {
311 | // Validate types at API boundary
312 | if (typeof args.address !== "string") {
313 | throw new Error("address must be a string");
314 | }
315 | if (typeof args.organizationSlug !== "string") {
316 | throw new Error("organizationSlug must be a string");
317 | }
318 |
319 | const result = await this.service.getAddressVotes({
320 | address: args.address,
321 | organizationSlug: args.organizationSlug,
322 | limit: typeof args.limit === "number" ? args.limit : undefined,
323 | afterCursor: typeof args.afterCursor === "string" ? args.afterCursor : undefined,
324 | });
325 |
326 | const content: TextContent[] = [
327 | {
328 | type: "text",
329 | text: JSON.stringify(result, null, 2),
330 | },
331 | ];
332 |
333 | return {
334 | content,
335 | pageInfo: {
336 | firstCursor: result.votes.pageInfo.firstCursor || null,
337 | lastCursor: result.votes.pageInfo.lastCursor || null,
338 | },
339 | };
340 | } catch (error) {
341 | throw new Error(
342 | `Error fetching address votes: ${
343 | error instanceof Error ? error.message : "Unknown error"
344 | }`
345 | );
346 | }
347 | }
348 |
349 | if (name === "get-address-received-delegations") {
350 | try {
351 | if (typeof args.address !== "string") {
352 | throw new Error("address must be a string");
353 | }
354 | if (typeof args.organizationSlug !== "string") {
355 | throw new Error("organizationSlug must be a string");
356 | }
357 |
358 | const result = await this.service.getAddressReceivedDelegations({
359 | address: args.address,
360 | organizationSlug: args.organizationSlug,
361 | limit: typeof args.limit === "number" ? args.limit : undefined,
362 | sortBy: typeof args.sortBy === "string" ? (args.sortBy as "votes") : undefined,
363 | isDescending: typeof args.isDescending === "boolean" ? args.isDescending : undefined,
364 | });
365 |
366 | const content: TextContent[] = [
367 | {
368 | type: "text",
369 | text: JSON.stringify(result, null, 2),
370 | },
371 | ];
372 |
373 | return { content };
374 | } catch (error) {
375 | throw new Error(
376 | `Error fetching received delegations: ${
377 | error instanceof Error ? error.message : "Unknown error"
378 | }`
379 | );
380 | }
381 | }
382 |
383 | if (name === "get-delegate-statement") {
384 | try {
385 | if (typeof args.address !== "string") {
386 | throw new Error("address must be a string");
387 | }
388 |
389 | // Check for mutually exclusive parameters
390 | if (typeof args.governorId === "string" && typeof args.organizationSlug === "string") {
391 | throw new Error("Cannot provide both governorId and organizationSlug");
392 | }
393 |
394 | let result;
395 | if (typeof args.governorId === "string") {
396 | result = await this.service.getDelegateStatement({
397 | address: args.address,
398 | governorId: args.governorId
399 | });
400 | } else if (typeof args.organizationSlug === "string") {
401 | result = await this.service.getDelegateStatement({
402 | address: args.address,
403 | organizationSlug: args.organizationSlug
404 | });
405 | } else {
406 | throw new Error("Either governorId or organizationSlug must be provided");
407 | }
408 |
409 | const content: TextContent[] = [
410 | {
411 | type: "text",
412 | text: JSON.stringify(result, null, 2),
413 | },
414 | ];
415 |
416 | return { content };
417 | } catch (error) {
418 | throw new Error(
419 | `Error fetching delegate statement: ${
420 | error instanceof Error ? error.message : "Unknown error"
421 | }`
422 | );
423 | }
424 | }
425 |
426 | if (name === "get-address-governances") {
427 | try {
428 | if (typeof args.address !== "string") {
429 | throw new Error("address must be a string");
430 | }
431 |
432 | const result = await this.service.getAddressGovernances({
433 | address: args.address,
434 | });
435 |
436 | const content: TextContent[] = [
437 | {
438 | type: "text",
439 | text: JSON.stringify(result, null, 2),
440 | },
441 | ];
442 |
443 | return { content };
444 | } catch (error) {
445 | throw new Error(
446 | `Error fetching address governances: ${
447 | error instanceof Error ? error.message : "Unknown error"
448 | }`
449 | );
450 | }
451 | }
452 |
453 | if (name === "get-proposal-timeline") {
454 | try {
455 | if (typeof args.proposalId !== 'string') {
456 | throw new Error('proposalId must be a string');
457 | }
458 | const result = await this.service.getProposalTimeline({
459 | proposalId: args.proposalId
460 | });
461 |
462 | if (!result.proposal) {
463 | throw new Error('Proposal not found');
464 | }
465 |
466 | const content: TextContent[] = [
467 | {
468 | type: "text",
469 | text: JSON.stringify(result, null, 2)
470 | }
471 | ];
472 | return { content };
473 | } catch (error) {
474 | throw new Error(
475 | `Error fetching proposal timeline: ${
476 | error instanceof Error ? error.message : "Unknown error"
477 | }`
478 | );
479 | }
480 | }
481 |
482 | if (name === "get-proposal-voters") {
483 | try {
484 | if (typeof args.proposalId !== "string") {
485 | throw new Error("proposalId must be a string");
486 | }
487 |
488 | const result = await this.service.getProposalVoters({
489 | proposalId: args.proposalId,
490 | limit: typeof args.limit === "number" ? args.limit : undefined,
491 | afterCursor: typeof args.afterCursor === "string" ? args.afterCursor : undefined,
492 | beforeCursor: typeof args.beforeCursor === "string" ? args.beforeCursor : undefined,
493 | sortBy: typeof args.sortBy === "string" ? args.sortBy as "votes" | "timestamp" : undefined,
494 | isDescending: typeof args.isDescending === "boolean" ? args.isDescending : undefined
495 | });
496 |
497 | if (!result?.votes?.nodes) {
498 | return {
499 | content: [],
500 | pageInfo: {
501 | firstCursor: null,
502 | lastCursor: null,
503 | },
504 | };
505 | }
506 |
507 | const content: TextContent[] = [
508 | {
509 | type: "text",
510 | text: JSON.stringify(result, null, 2)
511 | }
512 | ];
513 |
514 | return {
515 | content,
516 | pageInfo: {
517 | firstCursor: result.votes.pageInfo.firstCursor || null,
518 | lastCursor: result.votes.pageInfo.lastCursor || null,
519 | },
520 | };
521 | } catch (error) {
522 | throw new Error(
523 | `Error fetching proposal voters: ${
524 | error instanceof Error ? error.message : "Unknown error"
525 | }`
526 | );
527 | }
528 | }
529 |
530 | if (name === "get-address-metadata") {
531 | const { address } = args as { address: string };
532 | const result = await this.service.getAddressMetadata({
533 | address,
534 | });
535 | return {
536 | content: [{
537 | type: "text",
538 | text: JSON.stringify(result, null, 2),
539 | }],
540 | };
541 | }
542 |
543 | if (name === "get-proposal-security-analysis") {
544 | try {
545 | if (typeof args.proposalId !== "string") {
546 | throw new Error("proposalId must be a string");
547 | }
548 |
549 | const result = await this.service.getProposalSecurityAnalysis({
550 | proposalId: args.proposalId
551 | });
552 |
553 | const content: TextContent[] = [
554 | {
555 | type: "text",
556 | text: JSON.stringify(result, null, 2)
557 | }
558 | ];
559 |
560 | return { content };
561 | } catch (error) {
562 | throw new Error(
563 | `Error fetching proposal security analysis: ${
564 | error instanceof Error ? error.message : "Unknown error"
565 | }`
566 | );
567 | }
568 | }
569 |
570 | if (name === "get-proposal-votes-cast") {
571 | try {
572 | if (typeof args.id !== "string") {
573 | throw new Error("id must be a string");
574 | }
575 |
576 | const result = await this.service.getProposalVotesCast({
577 | id: args.id
578 | });
579 |
580 | const content: TextContent[] = [
581 | {
582 | type: "text",
583 | text: JSON.stringify(result, null, 2)
584 | }
585 | ];
586 |
587 | return { content };
588 | } catch (error) {
589 | throw new Error(
590 | `Error fetching proposal votes cast: ${
591 | error instanceof Error ? error.message : "Unknown error"
592 | }`
593 | );
594 | }
595 | }
596 |
597 | if (name === "get-proposal-votes-cast-list") {
598 | try {
599 | if (typeof args.id !== "string") {
600 | throw new Error("id must be a string");
601 | }
602 |
603 | const result = await this.service.getProposalVotesCastList({
604 | id: args.id
605 | });
606 |
607 | const content: TextContent[] = [
608 | {
609 | type: "text",
610 | text: JSON.stringify(result, null, 2)
611 | }
612 | ];
613 |
614 | return { content };
615 | } catch (error) {
616 | throw new Error(
617 | `Error fetching proposal votes cast list: ${
618 | error instanceof Error ? error.message : "Unknown error"
619 | }`
620 | );
621 | }
622 | }
623 |
624 | throw new Error(`Unknown tool: ${name}`);
625 | });
626 | }
627 |
628 | async start() {
629 | const transport = new StdioServerTransport();
630 | await this.server.connect(transport);
631 | console.error("Tally MCP Server running on stdio");
632 | }
633 | }
634 |
```
--------------------------------------------------------------------------------
/LLM-API-GUIDE.txt:
--------------------------------------------------------------------------------
```
1 |
2 |
3 | Tally API - LLM Query Construction Rules (Mandatory & Unbreakable)
4 |
5 | Introduction
6 |
7 | This document outlines the mandatory and unbreakable rules for Large Language Models (LLMs) when constructing queries for the Tally API. These rules are not suggestions—they must be strictly followed to ensure correct, efficient, and error-free GraphQL queries. Failure to adhere to any of these rules will result in query errors, inaccurate data, and is considered a fatal error. There is no acceptable deviation from these rules.
8 |
9 | Core Principles
10 |
11 | Never Assume: You must not assume any default values or behaviors for sort, filter, or other optional input parameters. You must explicitly declare them in the query.
12 |
13 | Type Awareness: You must always be acutely aware of the GraphQL types involved, especially interface and union types, and use inline fragments accordingly. Failure to do so is a fatal error.
14 |
15 | Fragment Prioritization: You must use fragments to minimize repetition, improve maintainability, and ensure efficient queries. Not using fragments is unacceptable.
16 |
17 | Explicit Field Selection: You must always explicitly request each field you need, and never assume fields will be returned automatically.
18 |
19 | Pagination: You must always use pagination where appropriate to ensure complete query results are retrieved, using the page input and pageInfo fields.
20 |
21 | Correct API Use: You must adhere to API constraints. Some queries have required fields that must be used correctly.
22 |
23 | Schema Consultation: You must consult the complete schema reference before creating any queries.
24 |
25 | Multi-step Queries: You must properly structure multi-step queries into a sequence of dependent queries if data from one query is needed for a subsequent query.
26 |
27 | Fragment Usage: All Fragments must be used, and any unused fragments must be removed.
28 |
29 | Rule 1: Interface and Union Type Handling (Mandatory)
30 |
31 | Problem: The nodes field in paginated queries often returns a list of types that implement a GraphQL interface (like Node), or are part of a union type. You cannot query fields directly on the interface type.
32 |
33 | Solution: You must use inline fragments (... on TypeName) to access fields on the concrete types within a list of interface types. Failure to do so is a fatal error.
34 |
35 | Example (Correct):
36 |
37 | query GetOrganizations {
38 | organizations {
39 | nodes {
40 | ... on Organization { # Correct: Uses inline fragment
41 | id
42 | name
43 | slug
44 | metadata {
45 | icon
46 | }
47 | }
48 | }
49 | pageInfo {
50 | firstCursor
51 | lastCursor
52 | count
53 | }
54 | }
55 | }
56 | content_copy
57 | download
58 | Use code with caution.
59 | Graphql
60 |
61 | Example (Incorrect - Avoid):
62 |
63 | query GetOrganizations {
64 | organizations {
65 | nodes {
66 | id # Incorrect: querying on the interface directly
67 | name # Incorrect: querying on the interface directly
68 | slug # Incorrect: querying on the interface directly
69 | }
70 | }
71 | }
72 | content_copy
73 | download
74 | Use code with caution.
75 | Graphql
76 |
77 | Specific Error Case: Attempting to query fields directly on the nodes field when querying votes without the ... on Vote fragment. This is a fatal error.
78 |
79 | query GetVotes {
80 | votes(input: {
81 | filters: {
82 | voter: "0x1B686eE8E31c5959D9F5BBd8122a58682788eeaD"
83 | }
84 | }) {
85 | nodes {
86 | type # Error: Didn't use ... on Vote
87 | }
88 | }
89 | }
90 | content_copy
91 | download
92 | Use code with caution.
93 | Graphql
94 |
95 | Prevention: This error is a result of not following rule 1. This could also be prevented by consulting the schema first, before creating the query.
96 |
97 | Action: Always use inline fragments (... on TypeName) inside the nodes list, and any other location where interface types can be returned, to query the specific fields of the concrete type. Failure to do so is a fatal error.
98 |
99 | Rule 2: Explicit Sort and Filter Inputs (Mandatory)
100 |
101 | Problem: Queries with sort or filter options often have required input types that must be fully populated.
102 |
103 | Solution: You must never assume default sort or filter values. You must always explicitly provide them in the query if you need them. Even if you don't need sorting or filtering, you must provide an empty input object.
104 |
105 | Example (Correct):
106 |
107 | query GetProposals($input: ProposalsInput!) {
108 | proposals(input: $input) {
109 | nodes {
110 | ... on Proposal {
111 | id
112 | metadata {
113 | title
114 | }
115 | status
116 | }
117 | }
118 | pageInfo {
119 | firstCursor
120 | lastCursor
121 | count
122 | }
123 | }
124 | }
125 | content_copy
126 | download
127 | Use code with caution.
128 | Graphql
129 | * **Input:**
130 | content_copy
131 | download
132 | Use code with caution.
133 | input ProposalsInput {
134 | filters: ProposalsFiltersInput
135 | page: PageInput
136 | sort: ProposalsSortInput
137 | }
138 |
139 | input ProposalsFiltersInput {
140 | governorId: AccountID
141 | includeArchived: Boolean
142 | isDraft: Boolean
143 | organizationId: IntID
144 | proposer: Address
145 | }
146 | input ProposalsSortInput {
147 | isDescending: Boolean!
148 | sortBy: ProposalsSortBy!
149 | }
150 | enum ProposalsSortBy {
151 | id
152 | }
153 |
154 | input PageInput {
155 | afterCursor: String
156 | beforeCursor: String
157 | limit: Int
158 | }
159 | content_copy
160 | download
161 | Use code with caution.
162 | Graphql
163 | * **Query:** (with optional sort, and filters)
164 | content_copy
165 | download
166 | Use code with caution.
167 | query GetProposalsWithSortAndFilter {
168 | proposals(input: {
169 | filters: {
170 | governorId: "eip155:1:0x123abc"
171 | includeArchived: true
172 | },
173 | sort: {
174 | sortBy: id
175 | isDescending: false
176 | },
177 | page: {
178 | limit: 10
179 | }
180 | })
181 | {
182 | nodes {
183 | ... on Proposal {
184 | id
185 | metadata {
186 | title
187 | }
188 | status
189 | }
190 | }
191 | pageInfo {
192 | firstCursor
193 | lastCursor
194 | count
195 | }
196 | }
197 | }
198 | content_copy
199 | download
200 | Use code with caution.
201 | Graphql
202 |
203 | Example (Incorrect - Avoid):
204 |
205 | query GetProposals {
206 | proposals {
207 | nodes {
208 | id
209 | metadata {
210 | title
211 | }
212 | status
213 | }
214 | }
215 | }
216 | content_copy
217 | download
218 | Use code with caution.
219 | Graphql
220 |
221 | Action: Always provide a valid input object for queries that require filters or sorts. Use null if no sorting or filtering is needed for a nullable input, but if the filter is required, use an empty object when no filters are required. Failure to do so is a fatal error.
222 |
223 | Rule 3: Fragment Usage (Mandatory)
224 |
225 | Problem: Repeated field selections in multiple queries make the code less maintainable and are prone to errors.
226 |
227 | Solution: You must use fragments to group common field selections and reuse them across multiple queries. Not using fragments is unacceptable.
228 |
229 | Example (Correct):
230 |
231 | fragment BasicProposalDetails on Proposal {
232 | id
233 | onchainId
234 | metadata {
235 | title
236 | description
237 | }
238 | status
239 | }
240 |
241 |
242 | query GetProposals($input: ProposalsInput!) {
243 | proposals(input: $input) {
244 | nodes {
245 | ... on Proposal {
246 | ...BasicProposalDetails
247 | }
248 | }
249 | pageInfo {
250 | firstCursor
251 | lastCursor
252 | count
253 | }
254 | }
255 | }
256 |
257 | query GetSingleProposal($input: ProposalInput!) {
258 | proposal(input: $input) {
259 | ...BasicProposalDetails
260 | }
261 | }
262 | content_copy
263 | download
264 | Use code with caution.
265 | Graphql
266 |
267 | Example (Incorrect - Avoid):
268 |
269 | query GetProposals {
270 | proposals {
271 | nodes {
272 | id
273 | onchainId
274 | metadata {
275 | title
276 | description
277 | }
278 | status
279 | }
280 | }
281 | }
282 |
283 | query GetSingleProposal {
284 | proposal(input: {id: 123}) {
285 | id
286 | onchainId
287 | metadata {
288 | title
289 | description
290 | }
291 | status
292 | }
293 | }
294 | content_copy
295 | download
296 | Use code with caution.
297 | Graphql
298 |
299 | Action: Always create and use fragments, and make them focused, and reusable across multiple queries. Not using fragments is unacceptable.
300 |
301 | Rule 4: Explicit Field Selection (Mandatory)
302 |
303 | Problem: Assuming the API will return certain fields if they aren't specifically requested.
304 |
305 | Solution: You must always request every field you need in your query.
306 |
307 | Example (Correct):
308 |
309 | query GetOrganization($input: OrganizationInput!) {
310 | organization(input: $input) {
311 | id
312 | name
313 | slug
314 | metadata {
315 | icon
316 | description
317 | socials {
318 | website
319 | }
320 | }
321 | }
322 | }
323 | content_copy
324 | download
325 | Use code with caution.
326 | Graphql
327 |
328 | Example (Incorrect - Avoid):
329 |
330 | query GetOrganization {
331 | organization { # Incorrect: Assuming all fields are returned by default
332 | name
333 | slug
334 | }
335 | }
336 | content_copy
337 | download
338 | Use code with caution.
339 | Graphql
340 |
341 | Action: List out every field you need in the query, and avoid implied or implicit field selections.
342 |
343 | Rule 5: Input Type Validation (Mandatory)
344 |
345 | Problem: Using the wrong types when providing input values to a query.
346 |
347 | Solution: Check that all values passed as inputs to a query match the type declared in the input. Failure to do so is a fatal error.
348 |
349 | Example (Correct):
350 |
351 | query GetAccount($id: AccountID!) {
352 | account(id: $id) {
353 | id
354 | name
355 | address
356 | ens
357 | picture
358 | }
359 | }
360 | content_copy
361 | download
362 | Use code with caution.
363 | Graphql
364 |
365 | Query
366 |
367 | query GetAccountCorrect {
368 | account(id:"eip155:1:0x123") {
369 | id
370 | name
371 | address
372 | ens
373 | picture
374 | }
375 | }
376 | content_copy
377 | download
378 | Use code with caution.
379 | Graphql
380 | * The `id` argument correctly uses the `AccountID` type, which is a string representing a CAIP-10 ID.
381 | content_copy
382 | download
383 | Use code with caution.
384 |
385 | Specific Error Case: Attempting to use a plain integer for organizationId in proposal queries. This is a fatal error.
386 |
387 | query GetProposals {
388 | proposals(input: {
389 | filters: {
390 | organizationId: 1 # Wrong format for ID
391 | }
392 | })
393 | {
394 | nodes {
395 | ... on Proposal {
396 | id
397 | }
398 | }
399 | }
400 | }
401 | content_copy
402 | download
403 | Use code with caution.
404 | Graphql
405 | * **Prevention:** This error is caused by not following rule 5, and also the ID type definitions.
406 | content_copy
407 | download
408 | Use code with caution.
409 |
410 | Example (Incorrect - Avoid):
411 |
412 | query GetAccount($id: AccountID!) {
413 | account(id: $id) {
414 | id
415 | name
416 | address
417 | }
418 | }
419 | content_copy
420 | download
421 | Use code with caution.
422 | Graphql
423 |
424 | Query
425 |
426 | query GetAccountIncorrect {
427 | account(id:123) { # Incorrect: Using an Int when an AccountID is expected.
428 | id
429 | name
430 | address
431 | ens
432 | picture
433 | }
434 | }
435 | content_copy
436 | download
437 | Use code with caution.
438 | Graphql
439 |
440 | Action: Ensure you're using the correct type. Int cannot be used where an IntID, AccountID, HashID or AssetID type is required. Failure to do so is a fatal error.
441 |
442 | ID Type Definitions
443 |
444 | AccountID: A CAIP-10 compliant account id. (e.g., "eip155:1:0x7e90e03654732abedf89Faf87f05BcD03ACEeFdc")
445 |
446 | AssetID: A CAIP-19 compliant asset id. (e.g., "eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f")
447 |
448 | IntID: A 64-bit integer represented as a string. (e.g., "1234567890")
449 |
450 | HashID: A CAIP-2 scoped identifier for identifying transactions across chains. (e.g., "eip155:1:0xDEAD")
451 |
452 | BlockID: A CAIP-2 scoped identifier for identifying blocks across chains. (e.g., "eip155:1:15672")
453 |
454 | ChainID: A CAIP-2 compliant chain ID. (e.g., "eip155:1")
455 |
456 | Address: A 20 byte ethereum address, represented as 0x-prefixed hexadecimal. (e.g., "0x1234567800000000000000000000000000000abc")
457 |
458 | Rule 6: Enum Usage (Mandatory)
459 |
460 | Problem: Using a string value when an enum type is expected.
461 |
462 | Solution: Always use the correct values for an enum type. Failure to do so is a fatal error.
463 |
464 | Example (Correct)
465 |
466 | query GetVotes($input: VotesInput!) {
467 | votes(input: $input) {
468 | nodes {
469 | id
470 | type
471 | }
472 | }
473 | }
474 | content_copy
475 | download
476 | Use code with caution.
477 | Graphql
478 |
479 | Input:
480 |
481 | input VotesInput {
482 | filters: VotesFiltersInput
483 | page: PageInput
484 | sort: VotesSortInput
485 | }
486 |
487 | input VotesFiltersInput {
488 | proposalId: IntID
489 | proposalIds: [IntID!]
490 | voter: Address
491 | includePendingVotes: Boolean
492 | type: VoteType
493 | }
494 | enum VoteType {
495 | abstain
496 | against
497 | for
498 | pendingabstain
499 | pendingagainst
500 | pendingfor
501 | }
502 | content_copy
503 | download
504 | Use code with caution.
505 | Graphql
506 |
507 | Query: (Correctly using an enum type)
508 |
509 | query GetVotesFor {
510 | votes(input: {
511 | filters: {
512 | type: for
513 | proposalId: 123
514 | }
515 | })
516 | {
517 | nodes {
518 | id
519 | type
520 | }
521 | }
522 | }
523 | content_copy
524 | download
525 | Use code with caution.
526 | Graphql
527 |
528 | Example (Incorrect - Avoid):
529 |
530 | query GetVotesFor {
531 | votes(input: {
532 | filters: {
533 | type: "for" # Incorrect: using a string, when a VoteType enum is expected
534 | proposalId: 123
535 | }
536 | })
537 | {
538 | nodes {
539 | id
540 | type
541 | }
542 | }
543 | }
544 | content_copy
545 | download
546 | Use code with caution.
547 | Graphql
548 |
549 | Action: Always ensure the values of enum types match the provided options, and that you are not using a string when an enum is expected. Failure to do so is a fatal error.
550 |
551 | Rule 7: Pagination Handling (Mandatory)
552 |
553 | Problem: Queries that return paginated data do not return complete results if pagination is not handled.
554 |
555 | Solution: You must always use the page input with appropriate limit, afterCursor and beforeCursor values to ensure you are retrieving all the results that you want. You must also use the pageInfo field on the returned type to use the cursors.
556 |
557 | Example (Correct):
558 |
559 | query GetPaginatedProposals($input: ProposalsInput!) {
560 | proposals(input: $input) {
561 | nodes {
562 | ... on Proposal {
563 | id
564 | metadata {
565 | title
566 | }
567 | }
568 | }
569 | pageInfo {
570 | firstCursor
571 | lastCursor
572 | count
573 | }
574 | }
575 | }
576 | content_copy
577 | download
578 | Use code with caution.
579 | Graphql
580 | * **Input**
581 | content_copy
582 | download
583 | Use code with caution.
584 | input ProposalsInput {
585 | filters: ProposalsFiltersInput
586 | page: PageInput
587 | sort: ProposalsSortInput
588 | }
589 |
590 | input ProposalsFiltersInput {
591 | governorId: AccountID
592 | includeArchived: Boolean
593 | isDraft: Boolean
594 | organizationId: IntID
595 | proposer: Address
596 | }
597 | input ProposalsSortInput {
598 | isDescending: Boolean!
599 | sortBy: ProposalsSortBy!
600 | }
601 | enum ProposalsSortBy {
602 | id
603 | }
604 |
605 | input PageInput {
606 | afterCursor: String
607 | beforeCursor: String
608 | limit: Int
609 | }
610 | content_copy
611 | download
612 | Use code with caution.
613 | Graphql
614 |
615 | Query:
616 |
617 | query GetProposalsWithPagination {
618 | proposals(input: {
619 | page: {
620 | limit: 20
621 | }
622 | }) {
623 | nodes {
624 | ... on Proposal {
625 | id
626 | metadata {
627 | title
628 | }
629 | }
630 | }
631 | pageInfo {
632 | firstCursor
633 | lastCursor
634 | count
635 | }
636 | }
637 | }
638 | content_copy
639 | download
640 | Use code with caution.
641 | Graphql
642 |
643 | Query: (Using cursors to get the next page of results)
644 |
645 | query GetProposalsWithPagination {
646 | proposals(input: {
647 | page: {
648 | limit: 20
649 | afterCursor: "cursorFromPreviousQuery"
650 | }
651 | }) {
652 | nodes {
653 | ... on Proposal {
654 | id
655 | metadata {
656 | title
657 | }
658 | }
659 | }
660 | pageInfo {
661 | firstCursor
662 | lastCursor
663 | count
664 | }
665 | }
666 | }
667 | content_copy
668 | download
669 | Use code with caution.
670 | Graphql
671 |
672 | Example (Incorrect - Avoid):
673 |
674 | query GetProposals { # Incorrect: Not using the `page` input.
675 | proposals {
676 | nodes {
677 | ... on Proposal {
678 | id
679 | metadata {
680 | title
681 | }
682 | }
683 | }
684 | }
685 | }
686 | content_copy
687 | download
688 | Use code with caution.
689 | Graphql
690 |
691 | Action: Always use the page input with a limit, and use the cursors to iterate through pages, especially when you are working with paginated data. Failure to do so may result in incomplete data.
692 |
693 | Rule 8: Correctly Querying Related Data (Mandatory)
694 |
695 | Problem: Attempting to query related data as nested fields within a type will lead to errors if the related data must be fetched in a separate query.
696 |
697 | Solution: You must fetch related data by using separate queries, instead of assuming that related data is queryable as nested fields.
698 |
699 | Example (Correct)
700 |
701 | query GetProposalAndVotes($proposalId: IntID!, $voter: Address) {
702 | proposal(input: { id: $proposalId}) {
703 | id
704 | metadata {
705 | title
706 | }
707 | status
708 | }
709 | votes(input: {
710 | filters: {
711 | proposalId: $proposalId
712 | voter: $voter
713 | }
714 | }) {
715 | nodes {
716 | ... on Vote {
717 | type
718 | amount
719 | voter {
720 | id
721 | name
722 | }
723 | }
724 | }
725 | }
726 | }
727 | content_copy
728 | download
729 | Use code with caution.
730 | Graphql
731 |
732 | Example (Incorrect - Avoid):
733 |
734 | query GetProposals {
735 | proposals {
736 | ... on Proposal {
737 | id
738 | metadata {
739 | title
740 | }
741 | votes(input: {
742 | filters: {
743 | voter: "0x..." # Incorrect: Trying to access votes as a nested field
744 | }
745 | })
746 | }
747 | }
748 | }
749 | content_copy
750 | download
751 | Use code with caution.
752 | Graphql
753 | * **Prevention:** This can be prevented by reading rule 8, and by consulting the schema before creating a query.
754 | content_copy
755 | download
756 | Use code with caution.
757 |
758 | Action: Do not attempt to fetch related data in the same query, instead, fetch it via a second query. Failure to do so will result in an error.
759 |
760 | Rule 9: API Constraints (Mandatory)
761 |
762 | Problem: Not all fields or properties are queryable in all situations. Some queries have explicit requirements that must be met.
763 |
764 | Solution: You must always check your query against the known API constraints, and ensure that all requirements are met.
765 |
766 | Example:
767 |
768 | The votes query requires that proposalId or proposalIds is provided in the input. This means you cannot query votes without first querying proposals. Failure to do so will result in an error.
769 |
770 | An error you may see is: "proposalId or proposalIds must be provided"
771 |
772 | Prevention: This can be prevented by reading rule 9, and by consulting the schema before creating a query.
773 |
774 | Action: Ensure all API constraits are met and that any required fields are provided when making a query. Failure to do so will result in an error.
775 |
776 | Rule 10: Multi-Step Queries (Mandatory)
777 |
778 | Problem: Some data can only be accessed by using multiple queries, and requires that data from one query be used as the input for a subsequent query.
779 |
780 | Solution: Properly construct multi-step queries by breaking them into a sequence of independent GraphQL queries. Ensure the output of one query is correctly used as input for the next query.
781 |
782 | Example
783 |
784 | If you need to fetch all the votes from a specific organization, you first need to get the organization id, then use that id to query all the proposals, and then finally, you need to query for all the votes associated with each proposal.
785 |
786 | Correct Example
787 |
788 | # Step 1: Get the organization ID using a query that filters by slug
789 |
790 | query GetOrganizationId($slug: String!) {
791 | organization(input: {slug: $slug}) {
792 | id
793 | }
794 | }
795 |
796 | # Step 2: Get the proposals for the given organization
797 | query GetProposalsForOrganization($organizationId: IntID!) {
798 | proposals(input: {
799 | filters: {
800 | organizationId: $organizationId
801 | }
802 | }) {
803 | nodes {
804 | ... on Proposal {
805 | id
806 | }
807 | }
808 | }
809 | }
810 |
811 | # Step 3: Get all the votes for all of the proposals.
812 | query GetVotesForProposals($proposalIds: [IntID!]!) {
813 | votes(input: {
814 | filters: {
815 | proposalIds: $proposalIds
816 | }
817 | })
818 | {
819 | nodes {
820 | ... on Vote {
821 | id
822 | type
823 | amount
824 | }
825 | }
826 | }
827 | }
828 | content_copy
829 | download
830 | Use code with caution.
831 | Graphql
832 | * **Action:** When a query requires data from another query, structure it as a multi-step query, and use the result of the first query as the input to the subsequent query.
833 | content_copy
834 | download
835 | Use code with caution.
836 |
837 | Rule 11: Fragment Usage (Mandatory)
838 |
839 | Problem: Defining fragments that aren't used creates unnecessary code.
840 |
841 | Solution: You must always use all defined fragments, and any unused fragments must be removed before submitting a query.
842 |
843 | Example
844 |
845 | fragment BasicAccountInfo on Account {
846 | id
847 | address
848 | ens
849 | }
850 |
851 | fragment VoteDetails on Vote {
852 | type
853 | amount
854 | }
855 |
856 | query GetVotes($input: VotesInput!) {
857 | votes(input: $input) {
858 | nodes {
859 | ... on Vote {
860 | ...VoteDetails # Correct: Using the VoteDetails fragment
861 | }
862 | }
863 | }
864 | }
865 | content_copy
866 | download
867 | Use code with caution.
868 | Graphql
869 | * **Prevention:** This can be prevented by following rule 3.
870 | * **Action:** All defined fragments *must* be used, and any unused fragments *must* be removed before submitting a query.
871 | content_copy
872 | download
873 | Use code with caution.
874 |
875 | Complete Schema Reference
876 |
877 | While we cannot provide the entire schema (it would be too lengthy), here are the core types and their most commonly used fields, and examples of the input types:
878 |
879 | type Account {
880 | id: ID!
881 | address: String!
882 | ens: String
883 | twitter: String
884 | name: String!
885 | bio: String!
886 | picture: String
887 | safes: [AccountID!]
888 | type: AccountType!
889 | votes(governorId: AccountID!): Uint256!
890 | proposalsCreatedCount(input: ProposalsCreatedCountInput!): Int!
891 | }
892 |
893 | enum AccountType {
894 | EOA
895 | SAFE
896 | }
897 | type Delegate {
898 | id: IntID!
899 | account: Account!
900 | chainId: ChainID
901 | delegatorsCount: Int!
902 | governor: Governor
903 | organization: Organization
904 | statement: DelegateStatement
905 | token: Token
906 | votesCount(blockNumber: Int): Uint256!
907 | }
908 |
909 | input DelegateInput {
910 | address: Address!
911 | governorId: AccountID
912 | organizationId: IntID
913 | }
914 |
915 | type DelegateStatement {
916 | id: IntID!
917 | address: Address!
918 | organizationID: IntID!
919 | statement: String!
920 | statementSummary: String
921 | isSeekingDelegation: Boolean
922 | issues: [Issue!]
923 | }
924 |
925 | type Delegation {
926 | id: IntID!
927 | blockNumber: Int!
928 | blockTimestamp: Timestamp!
929 | chainId: ChainID!
930 | delegator: Account!
931 | delegate: Account!
932 | organization: Organization!
933 | token: Token!
934 | votes: Uint256!
935 | }
936 | input DelegationInput {
937 | address: Address!
938 | tokenId: AssetID!
939 | }
940 | input DelegationsInput {
941 | filters: DelegationsFiltersInput!
942 | page: PageInput
943 | sort: DelegationsSortInput
944 | }
945 | input DelegationsFiltersInput {
946 | address: Address!
947 | governorId: AccountID
948 | organizationId: IntID
949 | }
950 | input DelegationsSortInput {
951 | isDescending: Boolean!
952 | sortBy: DelegationsSortBy!
953 | }
954 | enum DelegationsSortBy {
955 | id
956 | votes
957 | }
958 |
959 | type Governor {
960 | id: AccountID!
961 | chainId: ChainID!
962 | contracts: Contracts!
963 | isIndexing: Boolean!
964 | isBehind: Boolean!
965 | isPrimary: Boolean!
966 | kind: GovernorKind!
967 | name: String!
968 | organization: Organization!
969 | proposalStats: ProposalStats!
970 | parameters: GovernorParameters!
971 | quorum: Uint256!
972 | slug: String!
973 | timelockId: AccountID
974 | tokenId: AssetID!
975 | token: Token!
976 | type: GovernorType!
977 | delegatesCount: Int!
978 | delegatesVotesCount: Uint256!
979 | tokenOwnersCount: Int!
980 | metadata: GovernorMetadata
981 | }
982 | type GovernorContract {
983 | address: Address!
984 | type: GovernorType!
985 | }
986 |
987 | input GovernorInput {
988 | id: AccountID
989 | slug: String
990 | }
991 |
992 | type Organization {
993 | id: IntID!
994 | slug: String!
995 | name: String!
996 | chainIds: [ChainID!]!
997 | tokenIds: [AssetID!]!
998 | governorIds: [AccountID!]!
999 | metadata: OrganizationMetadata
1000 | creator: Account
1001 | hasActiveProposals: Boolean!
1002 | proposalsCount: Int!
1003 | delegatesCount: Int!
1004 | delegatesVotesCount: Uint256!
1005 | tokenOwnersCount: Int!
1006 | endorsementService: EndorsementService
1007 | }
1008 | input OrganizationInput {
1009 | id: IntID
1010 | slug: String
1011 | }
1012 | input OrganizationsInput {
1013 | filters: OrganizationsFiltersInput
1014 | page: PageInput
1015 | sort: OrganizationsSortInput
1016 | }
1017 | input OrganizationsFiltersInput {
1018 | address: Address
1019 | chainId: ChainID
1020 | hasLogo: Boolean
1021 | isMember: Boolean
1022 | }
1023 | input OrganizationsSortInput {
1024 | isDescending: Boolean!
1025 | sortBy: OrganizationsSortBy!
1026 | }
1027 |
1028 | enum OrganizationsSortBy {
1029 | id
1030 | name
1031 | explore
1032 | popular
1033 | }
1034 |
1035 | type Proposal {
1036 | id: IntID!
1037 | onchainId: String
1038 | block: Block
1039 | chainId: ChainID!
1040 | creator: Account
1041 | end: BlockOrTimestamp!
1042 | events: [ProposalEvent!]!
1043 | executableCalls: [ExecutableCall!]
1044 | governor: Governor!
1045 | metadata: ProposalMetadata!
1046 | organization: Organization!
1047 | proposer: Account
1048 | quorum: Uint256
1049 | status: ProposalStatus!
1050 | start: BlockOrTimestamp!
1051 | voteStats: [VoteStats!]
1052 | }
1053 | input ProposalInput {
1054 | id: IntID
1055 | onchainId: String
1056 | governorId: AccountID
1057 | includeArchived: Boolean
1058 | isLatest: Boolean
1059 | }
1060 | type ProposalMetadata {
1061 | title: String
1062 | description: String
1063 | eta: Int
1064 | ipfsHash: String
1065 | previousEnd: Int
1066 | timelockId: AccountID
1067 | txHash: Hash
1068 | discourseURL: String
1069 | snapshotURL: String
1070 | }
1071 |
1072 | input ProposalsInput {
1073 | filters: ProposalsFiltersInput
1074 | page: PageInput
1075 | sort: ProposalsSortInput
1076 | }
1077 | input ProposalsFiltersInput {
1078 | governorId: AccountID
1079 | includeArchived: Boolean
1080 | isDraft: Boolean
1081 | organizationId: IntID
1082 | proposer: Address
1083 | }
1084 | input ProposalsSortInput {
1085 | isDescending: Boolean!
1086 | sortBy: ProposalsSortBy!
1087 | }
1088 | enum ProposalsSortBy {
1089 | id
1090 | }
1091 |
1092 | type Token {
1093 | id: AssetID!
1094 | type: TokenType!
1095 | name: String!
1096 | symbol: String!
1097 | supply: Uint256!
1098 | decimals: Int!
1099 | eligibility: Eligibility
1100 | isIndexing: Boolean!
1101 | isBehind: Boolean!
1102 | }
1103 | type Vote {
1104 | id: IntID!
1105 | amount: Uint256!
1106 | block: Block!
1107 | chainId: ChainID!
1108 | isBridged: Boolean
1109 | proposal: Proposal!
1110 | reason: String
1111 | type: VoteType!
1112 | txHash: Hash!
1113 | voter: Account!
1114 | }
1115 | input VotesInput {
1116 | filters: VotesFiltersInput
1117 | page: PageInput
1118 | sort: VotesSortInput
1119 | }
1120 | input VotesFiltersInput {
1121 | proposalId: IntID
1122 | proposalIds: [IntID!]
1123 | voter: Address
1124 | includePendingVotes: Boolean
1125 | type: VoteType
1126 | }
1127 | input VotesSortInput {
1128 | isDescending: Boolean!
1129 | sortBy: VotesSortBy!
1130 | }
1131 | enum VotesSortBy {
1132 | id
1133 | amount
1134 | }
1135 |
1136 | enum VoteType {
1137 | abstain
1138 | against
1139 | for
1140 | pendingabstain
1141 | pendingagainst
1142 | pendingfor
1143 | }
1144 | content_copy
1145 | download
1146 | Use code with caution.
1147 | Graphql
1148 |
1149 | Best Practices Checklist (Pre-Flight Check)
1150 |
1151 | Before submitting any query, ensure you have:
1152 |
1153 | Interface Fragments: Used inline fragments (... on TypeName) wherever nodes or interface/union types are returned. Failure to do so is a fatal error.
1154 |
1155 | Sort/Filter Inputs: Provided a valid input object with correct types, and that mandatory filters are provided. Failure to do so is a fatal error.
1156 |
1157 | Fragments: Created and used fragments for reusable field selections. Not using fragments is unacceptable.
1158 |
1159 | Explicit Fields: Explicitly requested all the necessary fields. Failure to do so may result in errors.
1160 |
1161 | Type Validation: Verified that all input types match the expected type. Failure to do so is a fatal error.
1162 |
1163 | Enum Values: Verified that all enum values match the provided options for the enum. Failure to do so is a fatal error.
1164 |
1165 | Pagination: Implemented the correct pagination handling for paginated queries. Failure to do so may result in incomplete data.
1166 |
1167 | Related Data: Used separate queries to fetch related data, and did not attempt to access it in a nested fashion. Failure to do so will result in an error.
1168 |
1169 | API Constraints: Verified that API constraints are met, and that any required fields for the query are included. Failure to do so will result in an error.
1170 |
1171 | Multi-Step Queries: If a query requires data from another query, structured it as a multi-step query.
1172 |
1173 | Fragment Usage: All defined fragments must be used, and any unused fragments must be removed.
1174 |
1175 | LLM Instructions:
1176 |
1177 | Prioritize this Document: This document supersedes all other query building documentation. This should be your only reference.
1178 |
1179 | Strict Adherence: Any query generated must adhere to all of these rules. There is no acceptable deviation.
1180 |
1181 | Self-Check: Before submitting a query, your logic must run through each of these rules and the checklist to ensure full compliance.
1182 |
1183 | Error Prevention: Focus on preventing errors by strictly following the rules, and not merely detecting them after they occur.
1184 |
1185 | Iterative Refinement: If a query fails, do not merely try a different query. You must review this document, identify exactly which rule was broken, and revise the query to fix the problem. Failure to do this is a fatal error.
1186 |
1187 | Consult Schema: You must always consult the complete schema reference before creating any query. Failure to do so is a fatal error.
1188 |
1189 |
```