Skip to content

Commit 872fbb1

Browse files
Merge pull request #196 from blockful/feat/DEV-225_0n_votingPower_users_dont_need_to_receive_reminders
Feat/dev 225 0n voting power users dont need to receive reminders
2 parents 517060d + dfb5b52 commit 872fbb1

17 files changed

Lines changed: 282 additions & 52 deletions

File tree

apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.test.ts

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('VotingReminderTriggerHandler', () => {
5050
} as any;
5151

5252
mockAnticaptureClient = {
53-
listVotesOnchains: jest.fn()
53+
getProposalNonVoters: jest.fn()
5454
} as any;
5555

5656
handler = new VotingReminderTriggerHandler(
@@ -88,16 +88,8 @@ describe('VotingReminderTriggerHandler', () => {
8888

8989
// Setup mocks
9090
mockSubscriptionClient.getFollowedAddresses.mockResolvedValue(['0x123', '0x456']);
91-
mockAnticaptureClient.listVotesOnchains.mockResolvedValue([
92-
{
93-
daoId: 'test-dao',
94-
txHash: '0xtest',
95-
proposalId: 'proposal-123',
96-
voterAccountId: '0x123',
97-
support: '1',
98-
votingPower: '100',
99-
timestamp: '1234567890'
100-
} // Only 0x123 has voted
91+
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([
92+
{voter: '0x456'} // Only 0x456 hasn't voted
10193
]);
10294
mockSubscriptionClient.getWalletOwnersBatch.mockResolvedValue({
10395
'0x456': [mockUser] // Only 0x456 (non-voter) has users
@@ -112,11 +104,11 @@ describe('VotingReminderTriggerHandler', () => {
112104

113105
expect(result.messageId).toMatch(/voting-reminder-/);
114106
expect(mockSubscriptionClient.getFollowedAddresses).toHaveBeenCalledWith('test-dao');
115-
expect(mockAnticaptureClient.listVotesOnchains).toHaveBeenCalledWith({
116-
daoId: 'test-dao',
117-
proposalId_in: ['proposal-123'],
118-
voterAccountId_in: ['0x123', '0x456']
119-
});
107+
expect(mockAnticaptureClient.getProposalNonVoters).toHaveBeenCalledWith(
108+
'proposal-123',
109+
'test-dao',
110+
['0x123', '0x456']
111+
);
120112
expect(mockSubscriptionClient.getWalletOwnersBatch).toHaveBeenCalledWith(['0x456']);
121113
});
122114

@@ -131,7 +123,7 @@ describe('VotingReminderTriggerHandler', () => {
131123
const result = await handler.handleMessage(message);
132124

133125
expect(result.messageId).toMatch(/voting-reminder-/);
134-
expect(mockAnticaptureClient.listVotesOnchains).not.toHaveBeenCalled();
126+
expect(mockAnticaptureClient.getProposalNonVoters).not.toHaveBeenCalled();
135127
});
136128

137129
it('should skip when all users have already voted', async () => {
@@ -141,17 +133,7 @@ describe('VotingReminderTriggerHandler', () => {
141133
};
142134

143135
mockSubscriptionClient.getFollowedAddresses.mockResolvedValue(['0x123']);
144-
mockAnticaptureClient.listVotesOnchains.mockResolvedValue([
145-
{
146-
daoId: 'test-dao',
147-
txHash: '0xtest',
148-
proposalId: 'proposal-123',
149-
voterAccountId: '0x123',
150-
support: '1',
151-
votingPower: '100',
152-
timestamp: '1234567890'
153-
} // All addresses have voted
154-
]);
136+
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([]); // Empty array - all have voted
155137

156138
const result = await handler.handleMessage(message);
157139

@@ -166,7 +148,9 @@ describe('VotingReminderTriggerHandler', () => {
166148
};
167149

168150
mockSubscriptionClient.getFollowedAddresses.mockResolvedValue(['0x456']);
169-
mockAnticaptureClient.listVotesOnchains.mockResolvedValue([]); // No votes
151+
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([
152+
{voter: '0x456'}
153+
]);
170154
mockSubscriptionClient.getWalletOwnersBatch.mockResolvedValue({
171155
'0x456': [mockUser]
172156
});
@@ -275,7 +259,9 @@ describe('VotingReminderTriggerHandler', () => {
275259
.mockRejectedValueOnce(new Error('Network error'))
276260
.mockResolvedValueOnce(['0x456']);
277261

278-
mockAnticaptureClient.listVotesOnchains.mockResolvedValue([]);
262+
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([
263+
{voter: '0x456'}
264+
]);
279265
mockSubscriptionClient.getWalletOwnersBatch.mockResolvedValue({
280266
'0x456': [mockUser]
281267
});

apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,12 @@ export class VotingReminderTriggerHandler extends BaseTriggerHandler<VotingRemin
134134
daoId: string,
135135
subscribedAddresses: string[]
136136
): Promise<string[]> {
137-
const votes = await this.anticaptureClient!.listVotesOnchains({
137+
const nonVoters = await this.anticaptureClient!.getProposalNonVoters(
138+
proposalId,
138139
daoId,
139-
proposalId_in: [proposalId],
140-
voterAccountId_in: subscribedAddresses
141-
});
142-
143-
const voterAddresses = new Set(votes.map(vote => vote.voterAccountId.toLowerCase()));
144-
145-
return subscribedAddresses.filter(address =>
146-
!voterAddresses.has(address.toLowerCase())
140+
subscribedAddresses
147141
);
142+
return nonVoters.map(nv => nv.voter);
148143
}
149144

150145
/**

apps/integrated-tests/src/mocks/graphql-mock-setup.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,30 @@ export class GraphQLMockSetup {
130130
});
131131
}
132132

133+
// Handle proposal non-voters
134+
if (data.query?.includes('ProposalNonVoters')) {
135+
const proposalId = data.variables?.id;
136+
const addressesFilter = data.variables?.addresses || [];
137+
138+
// Get addresses that voted on this proposal
139+
const votersSet = new Set(
140+
votesData
141+
.filter((v: any) => v.proposalId === proposalId)
142+
.map((v: any) => getAddress(v.voterAccountId).toLowerCase())
143+
);
144+
145+
// Filter to find non-voters from the provided address list
146+
const nonVoterItems = addressesFilter
147+
.filter((addr: string) => !votersSet.has(getAddress(addr).toLowerCase()))
148+
.map((addr: string) => ({
149+
voter: getAddress(addr)
150+
}));
151+
152+
return Promise.resolve({
153+
data: { data: { proposalNonVoters: { items: nonVoterItems, totalCount: nonVoterItems.length } } }
154+
});
155+
}
156+
133157
// Handle DAOs
134158
if (data.query?.includes('GetDAOs')) {
135159
const uniqueDaoIds = [...new Set([

apps/logic-system/src/repositories/proposal.repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { QueryInput_Proposals_IncludeOptimisticProposals as boolEnum } from '@notification-system/anticapture-client/dist/gql/graphql';
12
import { ProposalDataSource, ProposalOnChain, ProposalOrNull, ListProposalsOptions } from '../interfaces/proposal.interface';
23
import { AnticaptureClient, ListProposalsQueryVariables } from '@notification-system/anticapture-client';
3-
import { QueryInput_Proposals_IncludeOptimisticProposals as boolEnum } from '@notification-system/anticapture-client/dist/gql/graphql'
44

55
export class ProposalRepository implements ProposalDataSource {
66
private anticaptureClient: AnticaptureClient;

packages/anticapture-client/dist/anticapture-client.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { AxiosInstance } from 'axios';
2+
import { z } from 'zod';
23
import type { GetProposalByIdQuery, ListProposalsQuery, ListProposalsQueryVariables, ListVotingPowerHistorysQueryVariables, ListVotesOnchainsQuery, ListVotesOnchainsQueryVariables } from './gql/graphql';
3-
import { ProcessedVotingPowerHistory } from './schemas';
4+
import { SafeProposalNonVotersResponseSchema, ProcessedVotingPowerHistory } from './schemas';
45
type ProposalItems = NonNullable<ListProposalsQuery['proposals']>['items'];
56
type VotingPowerHistoryItems = ProcessedVotingPowerHistory[];
67
type VotesOnchain = NonNullable<ListVotesOnchainsQuery['votesOnchains']['items'][0]>;
8+
type ProposalNonVoter = z.infer<typeof SafeProposalNonVotersResponseSchema>['proposalNonVoters']['items'][0];
79
export declare class AnticaptureClient {
810
private readonly httpClient;
911
constructor(httpClient: AxiosInstance);
@@ -52,6 +54,15 @@ export declare class AnticaptureClient {
5254
* @returns List of votes matching the criteria
5355
*/
5456
listVotesOnchains(variables: ListVotesOnchainsQueryVariables): Promise<VotesOnchain[]>;
57+
/**
58+
* Fetches addresses that haven't voted on a specific proposal
59+
* Note: API already filters for addresses with votingPower > 0
60+
* @param proposalId The proposal ID to check
61+
* @param daoId The DAO ID for the header
62+
* @param addresses Optional array of addresses to filter by
63+
* @returns List of non-voters with their voting power details
64+
*/
65+
getProposalNonVoters(proposalId: string, daoId: string, addresses?: string[]): Promise<ProposalNonVoter[]>;
5566
/**
5667
* List recent votes from all DAOs since a given timestamp
5768
* @param timestampGt Fetch votes with timestamp greater than this value

packages/anticapture-client/dist/anticapture-client.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,28 @@ class AnticaptureClient {
178178
return [];
179179
}
180180
}
181+
/**
182+
* Fetches addresses that haven't voted on a specific proposal
183+
* Note: API already filters for addresses with votingPower > 0
184+
* @param proposalId The proposal ID to check
185+
* @param daoId The DAO ID for the header
186+
* @param addresses Optional array of addresses to filter by
187+
* @returns List of non-voters with their voting power details
188+
*/
189+
async getProposalNonVoters(proposalId, daoId, addresses) {
190+
try {
191+
const variables = {
192+
id: proposalId,
193+
...(addresses && { addresses: addresses }),
194+
};
195+
const validated = await this.query(graphql_2.ProposalNonVotersDocument, schemas_1.SafeProposalNonVotersResponseSchema, variables, daoId);
196+
return validated.proposalNonVoters.items;
197+
}
198+
catch (error) {
199+
console.warn(`Error fetching non-voters for proposal ${proposalId}:`, error);
200+
return [];
201+
}
202+
}
181203
/**
182204
* List recent votes from all DAOs since a given timestamp
183205
* @param timestampGt Fetch votes with timestamp greater than this value

packages/anticapture-client/dist/gql/gql.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
1313
*/
1414
type Documents = {
1515
"query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}": typeof types.GetDaOsDocument;
16+
"query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": typeof types.ProposalNonVotersDocument;
1617
"query GetProposalById($id: String!) {\n proposal(id: $id) {\n id\n daoId\n proposerAccountId\n title\n description\n startBlock\n endBlock\n endTimestamp\n timestamp\n status\n forVotes\n againstVotes\n abstainVotes\n txHash\n }\n}\n\nquery ListProposals($skip: NonNegativeInt, $limit: PositiveInt, $orderDirection: queryInput_proposals_orderDirection, $status: JSON, $fromDate: Float, $fromEndDate: Float, $includeOptimisticProposals: queryInput_proposals_includeOptimisticProposals) {\n proposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n fromEndDate: $fromEndDate\n includeOptimisticProposals: $includeOptimisticProposals\n ) {\n items {\n id\n daoId\n proposerAccountId\n title\n description\n startBlock\n endBlock\n endTimestamp\n timestamp\n status\n forVotes\n againstVotes\n abstainVotes\n txHash\n }\n totalCount\n }\n}": typeof types.GetProposalByIdDocument;
1718
"query ListVotesOnchains($daoId: String!, $proposalId_in: [String!], $voterAccountId_in: [String!], $timestamp_gt: BigInt, $timestamp_gte: BigInt, $timestamp_lt: BigInt, $timestamp_lte: BigInt, $limit: Int, $orderBy: String, $orderDirection: String) {\n votesOnchains(\n where: {daoId: $daoId, proposalId_in: $proposalId_in, voterAccountId_in: $voterAccountId_in, timestamp_gt: $timestamp_gt, timestamp_gte: $timestamp_gte, timestamp_lt: $timestamp_lt, timestamp_lte: $timestamp_lte}\n limit: $limit\n orderBy: $orderBy\n orderDirection: $orderDirection\n ) {\n items {\n daoId\n txHash\n proposalId\n voterAccountId\n support\n votingPower\n timestamp\n reason\n }\n totalCount\n }\n}": typeof types.ListVotesOnchainsDocument;
1819
"query ListVotingPowerHistorys($where: votingPowerHistoryFilter, $limit: Int, $orderBy: String, $orderDirection: String) {\n votingPowerHistorys(\n where: $where\n limit: $limit\n orderBy: $orderBy\n orderDirection: $orderDirection\n ) {\n items {\n accountId\n timestamp\n votingPower\n delta\n daoId\n transactionHash\n delegation {\n delegatorAccountId\n delegatedValue\n }\n transfer {\n amount\n fromAccountId\n toAccountId\n }\n }\n }\n}": typeof types.ListVotingPowerHistorysDocument;
@@ -35,6 +36,10 @@ export declare function graphql(source: string): unknown;
3536
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
3637
*/
3738
export declare function graphql(source: "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}"): (typeof documents)["query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}"];
39+
/**
40+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
41+
*/
42+
export declare function graphql(source: "query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}"): (typeof documents)["query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}"];
3843
/**
3944
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
4045
*/

packages/anticapture-client/dist/gql/gql.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ exports.graphql = graphql;
3838
const types = __importStar(require("./graphql"));
3939
const documents = {
4040
"query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}": types.GetDaOsDocument,
41+
"query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": types.ProposalNonVotersDocument,
4142
"query GetProposalById($id: String!) {\n proposal(id: $id) {\n id\n daoId\n proposerAccountId\n title\n description\n startBlock\n endBlock\n endTimestamp\n timestamp\n status\n forVotes\n againstVotes\n abstainVotes\n txHash\n }\n}\n\nquery ListProposals($skip: NonNegativeInt, $limit: PositiveInt, $orderDirection: queryInput_proposals_orderDirection, $status: JSON, $fromDate: Float, $fromEndDate: Float, $includeOptimisticProposals: queryInput_proposals_includeOptimisticProposals) {\n proposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n fromEndDate: $fromEndDate\n includeOptimisticProposals: $includeOptimisticProposals\n ) {\n items {\n id\n daoId\n proposerAccountId\n title\n description\n startBlock\n endBlock\n endTimestamp\n timestamp\n status\n forVotes\n againstVotes\n abstainVotes\n txHash\n }\n totalCount\n }\n}": types.GetProposalByIdDocument,
4243
"query ListVotesOnchains($daoId: String!, $proposalId_in: [String!], $voterAccountId_in: [String!], $timestamp_gt: BigInt, $timestamp_gte: BigInt, $timestamp_lt: BigInt, $timestamp_lte: BigInt, $limit: Int, $orderBy: String, $orderDirection: String) {\n votesOnchains(\n where: {daoId: $daoId, proposalId_in: $proposalId_in, voterAccountId_in: $voterAccountId_in, timestamp_gt: $timestamp_gt, timestamp_gte: $timestamp_gte, timestamp_lt: $timestamp_lt, timestamp_lte: $timestamp_lte}\n limit: $limit\n orderBy: $orderBy\n orderDirection: $orderDirection\n ) {\n items {\n daoId\n txHash\n proposalId\n voterAccountId\n support\n votingPower\n timestamp\n reason\n }\n totalCount\n }\n}": types.ListVotesOnchainsDocument,
4344
"query ListVotingPowerHistorys($where: votingPowerHistoryFilter, $limit: Int, $orderBy: String, $orderDirection: String) {\n votingPowerHistorys(\n where: $where\n limit: $limit\n orderBy: $orderBy\n orderDirection: $orderDirection\n ) {\n items {\n accountId\n timestamp\n votingPower\n delta\n daoId\n transactionHash\n delegation {\n delegatorAccountId\n delegatedValue\n }\n transfer {\n amount\n fromAccountId\n toAccountId\n }\n }\n }\n}": types.ListVotingPowerHistorysDocument,

packages/anticapture-client/dist/gql/graphql.d.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export type Query = {
188188
tokenPrice?: Maybe<TokenPrice>;
189189
tokenPrices: TokenPricePage;
190190
tokens: TokenPage;
191-
/** Get total assets */
191+
/** Get historical Liquid Treasury (treasury without DAO tokens) directly from provider */
192192
totalAssets?: Maybe<Array<Maybe<Query_TotalAssets_Items>>>;
193193
transaction?: Maybe<Transaction>;
194194
/** Get transactions with their associated transfers and delegations, with optional filtering and sorting */
@@ -417,6 +417,7 @@ export type QueryTokensArgs = {
417417
};
418418
export type QueryTotalAssetsArgs = {
419419
days?: InputMaybe<QueryInput_TotalAssets_Days>;
420+
order?: InputMaybe<QueryInput_TotalAssets_Order>;
420421
};
421422
export type QueryTransactionArgs = {
422423
transactionHash: Scalars['String']['input'];
@@ -1451,6 +1452,10 @@ export declare enum QueryInput_TotalAssets_Days {
14511452
'180d' = "_180d",
14521453
'365d' = "_365d"
14531454
}
1455+
export declare enum QueryInput_TotalAssets_Order {
1456+
Asc = "asc",
1457+
Desc = "desc"
1458+
}
14541459
export declare enum QueryInput_Transactions_SortOrder {
14551460
Asc = "asc",
14561461
Desc = "desc"
@@ -1590,8 +1595,9 @@ export type Query_Proposals_Items_Items = {
15901595
};
15911596
export type Query_TotalAssets_Items = {
15921597
__typename?: 'query_totalAssets_items';
1593-
date: Scalars['String']['output'];
1594-
totalAssets: Scalars['String']['output'];
1598+
/** Unix timestamp in milliseconds */
1599+
date: Scalars['Float']['output'];
1600+
liquidTreasury: Scalars['Float']['output'];
15951601
};
15961602
export type Query_Transactions_Items_Items = {
15971603
__typename?: 'query_transactions_items_items';
@@ -2264,6 +2270,20 @@ export type GetDaOsQuery = {
22642270
}>;
22652271
};
22662272
};
2273+
export type ProposalNonVotersQueryVariables = Exact<{
2274+
id: Scalars['String']['input'];
2275+
addresses?: InputMaybe<Scalars['JSON']['input']>;
2276+
}>;
2277+
export type ProposalNonVotersQuery = {
2278+
__typename?: 'Query';
2279+
proposalNonVoters?: {
2280+
__typename?: 'proposalNonVoters_200_response';
2281+
items: Array<{
2282+
__typename?: 'query_proposalNonVoters_items_items';
2283+
voter: string;
2284+
} | null>;
2285+
} | null;
2286+
};
22672287
export type GetProposalByIdQueryVariables = Exact<{
22682288
id: Scalars['String']['input'];
22692289
}>;
@@ -2383,6 +2403,7 @@ export type ListVotingPowerHistorysQuery = {
23832403
};
23842404
};
23852405
export declare const GetDaOsDocument: DocumentNode<GetDaOsQuery, GetDaOsQueryVariables>;
2406+
export declare const ProposalNonVotersDocument: DocumentNode<ProposalNonVotersQuery, ProposalNonVotersQueryVariables>;
23862407
export declare const GetProposalByIdDocument: DocumentNode<GetProposalByIdQuery, GetProposalByIdQueryVariables>;
23872408
export declare const ListProposalsDocument: DocumentNode<ListProposalsQuery, ListProposalsQueryVariables>;
23882409
export declare const ListVotesOnchainsDocument: DocumentNode<ListVotesOnchainsQuery, ListVotesOnchainsQueryVariables>;

0 commit comments

Comments
 (0)