diff --git a/apps/dispatcher/src/services/triggers/base-trigger.service.ts b/apps/dispatcher/src/services/triggers/base-trigger.service.ts index 2836fa61..bff1540d 100644 --- a/apps/dispatcher/src/services/triggers/base-trigger.service.ts +++ b/apps/dispatcher/src/services/triggers/base-trigger.service.ts @@ -10,7 +10,7 @@ import { AnticaptureClient } from '@notification-system/anticapture-client'; * @template T - Type of event data being processed */ export abstract class BaseTriggerHandler implements TriggerHandler { - private daoChainCache: Map = new Map(); + private daoCache: Map = new Map(); /** * Creates a new instance of the BaseTriggerHandler @@ -102,26 +102,34 @@ export abstract class BaseTriggerHandler implements TriggerHandler { * @returns Chain ID for the DAO, or 1 (Ethereum mainnet) as default * @throws Error if anticaptureClient is not provided */ - protected async getChainIdForDao(daoId: string): Promise { + /** + * Gets full DAO info (chainId, alreadySupportCalldataReview) with caching + */ + protected async getDaoInfo(daoId: string): Promise<{ chainId: number; alreadySupportCalldataReview: boolean }> { if (!this.anticaptureClient) { - throw new Error('AnticaptureClient is required for getChainIdForDao'); + throw new Error('AnticaptureClient is required for getDaoInfo'); } - // Check cache first - if (this.daoChainCache.has(daoId)) { - return this.daoChainCache.get(daoId)!; + if (this.daoCache.has(daoId)) { + return this.daoCache.get(daoId)!; } - // Fetch DAOs and cache chain IDs const daos = await this.anticaptureClient.getDAOs(); - const daoMap = new Map(daos.map(dao => [dao.id, dao.chainId])); + for (const dao of daos) { + this.daoCache.set(dao.id, { + chainId: dao.chainId, + alreadySupportCalldataReview: dao.alreadySupportCalldataReview + }); + } - // Cache all DAOs - daoMap.forEach((chainId, id) => { - this.daoChainCache.set(id, chainId); - }); + return this.daoCache.get(daoId) || { chainId: 1, alreadySupportCalldataReview: false }; + } - // Return chain ID for requested DAO or default to Ethereum mainnet - return daoMap.get(daoId) || 1; + /** + * Gets the chain ID for a specific DAO, with caching + */ + protected async getChainIdForDao(daoId: string): Promise { + const info = await this.getDaoInfo(daoId); + return info.chainId; } } \ No newline at end of file diff --git a/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts b/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts index 947ec791..39f2c566 100644 --- a/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts +++ b/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts @@ -74,8 +74,8 @@ describe('NewProposalTriggerHandler', () => { mockAnticaptureClient = { getDAOs: jest.fn(async () => [ - { id: 'dao123', chainId: 1 }, - { id: 'dao456', chainId: 10 } + { id: 'dao123', chainId: 1, alreadySupportCalldataReview: false }, + { id: 'dao456', chainId: 10, alreadySupportCalldataReview: true } ]), getProposalById: jest.fn(), listProposals: jest.fn(), @@ -150,6 +150,38 @@ describe('NewProposalTriggerHandler', () => { expect(mockNotificationClient.sendNotification).not.toHaveBeenCalled(); }); + it('should include calldata review button when DAO does not support it natively', async () => { + const mockMessage: DispatcherMessage = { + triggerId: 'new-proposal', + events: [{ ...mockProposal, daoId: 'dao123' }] + }; + + await handler.handleMessage(mockMessage); + + const call = mockNotificationClient.sendNotification.mock.calls[0][0]; + const buttons = call.metadata?.buttons; + expect(buttons).toBeDefined(); + expect(buttons.some((b: any) => b.text.includes('Request a call-data review'))).toBe(true); + }); + + it('should NOT include calldata review button when DAO already supports it', async () => { + const mockMessage: DispatcherMessage = { + triggerId: 'new-proposal', + events: [{ ...mockProposal, id: 'prop789', daoId: 'dao456' }] + }; + + mockSubscriptionClient.shouldSend.mockResolvedValue([ + { user_id: '1', event_id: 'prop789', dao_id: 'dao456' } + ]); + + await handler.handleMessage(mockMessage); + + const call = mockNotificationClient.sendNotification.mock.calls[0][0]; + const buttons = call.metadata?.buttons; + expect(buttons).toBeDefined(); + expect(buttons.some((b: any) => b.text.includes('Request a call-data review'))).toBe(false); + }); + it('should extract title from multiline descriptions', async () => { const mockUsersForMultiline: User[] = [ { id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() } @@ -217,8 +249,8 @@ describe('NewProposalTriggerHandler - cross-DAO eventId deduplication', () => { } as unknown as NotificationClientFactory, { getDAOs: async () => [ - { id: 'ens.eth', chainId: 1 }, - { id: 'uniswap.eth', chainId: 1 } + { id: 'ens.eth', chainId: 1, alreadySupportCalldataReview: true }, + { id: 'uniswap.eth', chainId: 1, alreadySupportCalldataReview: false } ], } as unknown as AnticaptureClient ); diff --git a/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.ts b/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.ts index d6daa3a7..59bf5ec4 100644 --- a/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.ts +++ b/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.ts @@ -39,14 +39,15 @@ export class NewProposalTriggerHandler extends BaseTriggerHandler { title: proposalTitle }); - // Build buttons with transaction hash - const chainId = await this.getChainIdForDao(daoId); + // Build buttons with transaction hash and calldata review CTA + const daoInfo = await this.getDaoInfo(daoId); const buttons = buildButtons({ triggerType: 'newProposal', txHash: txHash, - chainId, + chainId: daoInfo.chainId, daoId, - proposalId + proposalId, + alreadySupportCalldataReview: daoInfo.alreadySupportCalldataReview }); await this.sendNotificationsToSubscribers( diff --git a/packages/anticapture-client/dist/anticapture-client.d.ts b/packages/anticapture-client/dist/anticapture-client.d.ts index 087a62d2..4b4ec858 100644 --- a/packages/anticapture-client/dist/anticapture-client.d.ts +++ b/packages/anticapture-client/dist/anticapture-client.d.ts @@ -38,6 +38,7 @@ export declare class AnticaptureClient { blockTime: number; votingDelay: string; chainId: number; + alreadySupportCalldataReview: boolean; }>>; /** * Fetches a single proposal by ID with full type safety diff --git a/packages/anticapture-client/dist/anticapture-client.js b/packages/anticapture-client/dist/anticapture-client.js index eb8924db..7a040309 100644 --- a/packages/anticapture-client/dist/anticapture-client.js +++ b/packages/anticapture-client/dist/anticapture-client.js @@ -127,7 +127,8 @@ class AnticaptureClient { // blockTime: dao.blockTime, // TODO: Uncomment when API supports this field blockTime: 12, // Temporary hardcoded value - Ethereum block time votingDelay: dao.votingDelay || '0', - chainId: dao.chainId + chainId: dao.chainId, + alreadySupportCalldataReview: dao.alreadySupportCalldataReview ?? false })); } catch (error) { diff --git a/packages/anticapture-client/dist/gql/gql.d.ts b/packages/anticapture-client/dist/gql/gql.d.ts index b033562d..593527c0 100644 --- a/packages/anticapture-client/dist/gql/gql.d.ts +++ b/packages/anticapture-client/dist/gql/gql.d.ts @@ -12,7 +12,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { - "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}": typeof types.GetDaOsDocument; + "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}": typeof types.GetDaOsDocument; "query ListOffchainProposals($skip: NonNegativeInt, $limit: PositiveInt, $orderDirection: queryInput_offchainProposals_orderDirection, $status: JSON, $fromDate: Float) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n }\n totalCount\n }\n}": typeof types.ListOffchainProposalsDocument; "query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": typeof types.ProposalNonVotersDocument; "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; @@ -37,7 +37,7 @@ export declare function graphql(source: string): unknown; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -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}"]; +export declare function graphql(source: "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}"): (typeof documents)["query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/anticapture-client/dist/gql/gql.js b/packages/anticapture-client/dist/gql/gql.js index ecc698c6..dc1fe791 100644 --- a/packages/anticapture-client/dist/gql/gql.js +++ b/packages/anticapture-client/dist/gql/gql.js @@ -37,7 +37,7 @@ exports.graphql = graphql; /* eslint-disable */ const types = __importStar(require("./graphql")); const documents = { - "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}": types.GetDaOsDocument, + "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}": types.GetDaOsDocument, "query ListOffchainProposals($skip: NonNegativeInt, $limit: PositiveInt, $orderDirection: queryInput_offchainProposals_orderDirection, $status: JSON, $fromDate: Float) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n }\n totalCount\n }\n}": types.ListOffchainProposalsDocument, "query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": types.ProposalNonVotersDocument, "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, diff --git a/packages/anticapture-client/dist/gql/graphql.d.ts b/packages/anticapture-client/dist/gql/graphql.d.ts index 5283b599..9da88d52 100644 --- a/packages/anticapture-client/dist/gql/graphql.d.ts +++ b/packages/anticapture-client/dist/gql/graphql.d.ts @@ -636,6 +636,7 @@ export type CompareVotes_200_Response = { }; export type Dao_200_Response = { __typename?: 'dao_200_response'; + alreadySupportCalldataReview: Scalars['Boolean']['output']; chainId: Scalars['Float']['output']; id: Scalars['String']['output']; proposalThreshold: Scalars['String']['output']; @@ -1681,6 +1682,7 @@ export type GetDaOsQuery = { id: string; votingDelay: string; chainId: number; + alreadySupportCalldataReview: boolean; }>; }; }; diff --git a/packages/anticapture-client/dist/gql/graphql.js b/packages/anticapture-client/dist/gql/graphql.js index 2dcff416..b8e0cd7f 100644 --- a/packages/anticapture-client/dist/gql/graphql.js +++ b/packages/anticapture-client/dist/gql/graphql.js @@ -410,7 +410,7 @@ var Timestamp_Const; (function (Timestamp_Const) { Timestamp_Const["Timestamp"] = "timestamp"; })(Timestamp_Const || (exports.Timestamp_Const = Timestamp_Const = {})); -exports.GetDaOsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetDAOs" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "daos" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "votingDelay" } }, { "kind": "Field", "name": { "kind": "Name", "value": "chainId" } }] } }] } }] } }] }; +exports.GetDaOsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetDAOs" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "daos" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "votingDelay" } }, { "kind": "Field", "name": { "kind": "Name", "value": "chainId" } }, { "kind": "Field", "name": { "kind": "Name", "value": "alreadySupportCalldataReview" } }] } }] } }] } }] }; exports.ListOffchainProposalsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "ListOffchainProposals" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "skip" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "NonNegativeInt" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "PositiveInt" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "queryInput_offchainProposals_orderDirection" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "status" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "JSON" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "fromDate" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Float" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "offchainProposals" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "skip" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "skip" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "limit" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "orderDirection" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "status" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "status" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "fromDate" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "fromDate" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "title" } }, { "kind": "Field", "name": { "kind": "Name", "value": "discussion" } }, { "kind": "Field", "name": { "kind": "Name", "value": "link" } }, { "kind": "Field", "name": { "kind": "Name", "value": "state" } }, { "kind": "Field", "name": { "kind": "Name", "value": "created" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "totalCount" } }] } }] } }] }; exports.ProposalNonVotersDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "ProposalNonVoters" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "addresses" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "JSON" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "proposalNonVoters" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "id" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "addresses" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "addresses" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "voter" } }] } }] } }] } }] }; exports.GetProposalByIdDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetProposalById" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "proposal" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "id" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "daoId" } }, { "kind": "Field", "name": { "kind": "Name", "value": "proposerAccountId" } }, { "kind": "Field", "name": { "kind": "Name", "value": "title" } }, { "kind": "Field", "name": { "kind": "Name", "value": "description" } }, { "kind": "Field", "name": { "kind": "Name", "value": "startBlock" } }, { "kind": "Field", "name": { "kind": "Name", "value": "endBlock" } }, { "kind": "Field", "name": { "kind": "Name", "value": "endTimestamp" } }, { "kind": "Field", "name": { "kind": "Name", "value": "timestamp" } }, { "kind": "Field", "name": { "kind": "Name", "value": "status" } }, { "kind": "Field", "name": { "kind": "Name", "value": "forVotes" } }, { "kind": "Field", "name": { "kind": "Name", "value": "againstVotes" } }, { "kind": "Field", "name": { "kind": "Name", "value": "abstainVotes" } }, { "kind": "Field", "name": { "kind": "Name", "value": "txHash" } }] } }] } }] }; diff --git a/packages/anticapture-client/dist/schemas.d.ts b/packages/anticapture-client/dist/schemas.d.ts index e553a4bf..9f7c1944 100644 --- a/packages/anticapture-client/dist/schemas.d.ts +++ b/packages/anticapture-client/dist/schemas.d.ts @@ -6,26 +6,31 @@ export declare const SafeDaosResponseSchema: z.ZodEffects; chainId: z.ZodNumber; + alreadySupportCalldataReview: z.ZodOptional; }, "strip", z.ZodTypeAny, { id: string; chainId: number; votingDelay?: string | undefined; + alreadySupportCalldataReview?: boolean | undefined; }, { id: string; chainId: number; votingDelay?: string | undefined; + alreadySupportCalldataReview?: boolean | undefined; }>, "many">; }, "strip", z.ZodTypeAny, { items: { id: string; chainId: number; votingDelay?: string | undefined; + alreadySupportCalldataReview?: boolean | undefined; }[]; }, { items: { id: string; chainId: number; votingDelay?: string | undefined; + alreadySupportCalldataReview?: boolean | undefined; }[]; }>>; }, "strip", z.ZodTypeAny, { @@ -34,6 +39,7 @@ export declare const SafeDaosResponseSchema: z.ZodEffects, { @@ -50,6 +57,7 @@ export declare const SafeDaosResponseSchema: z.ZodEffects; diff --git a/packages/anticapture-client/dist/schemas.js b/packages/anticapture-client/dist/schemas.js index 49894ed0..d2414ee9 100644 --- a/packages/anticapture-client/dist/schemas.js +++ b/packages/anticapture-client/dist/schemas.js @@ -13,7 +13,8 @@ exports.SafeDaosResponseSchema = zod_1.z.object({ items: zod_1.z.array(zod_1.z.object({ id: zod_1.z.string(), votingDelay: zod_1.z.string().optional(), - chainId: zod_1.z.number() + chainId: zod_1.z.number(), + alreadySupportCalldataReview: zod_1.z.boolean().optional() })) }).nullable() }).transform((data) => { diff --git a/packages/anticapture-client/queries/daos.graphql b/packages/anticapture-client/queries/daos.graphql index 8e92a740..0b95ecef 100644 --- a/packages/anticapture-client/queries/daos.graphql +++ b/packages/anticapture-client/queries/daos.graphql @@ -4,6 +4,7 @@ query GetDAOs { id votingDelay chainId + alreadySupportCalldataReview } } } \ No newline at end of file diff --git a/packages/anticapture-client/src/anticapture-client.ts b/packages/anticapture-client/src/anticapture-client.ts index 8498dc93..6b145f9a 100644 --- a/packages/anticapture-client/src/anticapture-client.ts +++ b/packages/anticapture-client/src/anticapture-client.ts @@ -144,7 +144,7 @@ export class AnticaptureClient { * Fetches all DAOs from the anticapture GraphQL API with full type safety * @returns Array of DAO objects with blockTime added */ - async getDAOs(): Promise> { + async getDAOs(): Promise> { try { const validated = await this.query(GetDaOsDocument, SafeDaosResponseSchema, undefined, undefined); return validated.daos.items.map((dao) => ({ @@ -152,7 +152,8 @@ export class AnticaptureClient { // blockTime: dao.blockTime, // TODO: Uncomment when API supports this field blockTime: 12, // Temporary hardcoded value - Ethereum block time votingDelay: dao.votingDelay || '0', - chainId: dao.chainId + chainId: dao.chainId, + alreadySupportCalldataReview: dao.alreadySupportCalldataReview ?? false })); } catch (error) { console.warn('Returning empty DAO list due to API error: ', error instanceof Error ? error.message : error); diff --git a/packages/anticapture-client/src/gql/gql.ts b/packages/anticapture-client/src/gql/gql.ts index 61f4301f..326e9650 100644 --- a/packages/anticapture-client/src/gql/gql.ts +++ b/packages/anticapture-client/src/gql/gql.ts @@ -14,7 +14,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { - "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}": typeof types.GetDaOsDocument, + "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}": typeof types.GetDaOsDocument, "query ListOffchainProposals($skip: NonNegativeInt, $limit: PositiveInt, $orderDirection: queryInput_offchainProposals_orderDirection, $status: JSON, $fromDate: Float) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n }\n totalCount\n }\n}": typeof types.ListOffchainProposalsDocument, "query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": typeof types.ProposalNonVotersDocument, "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, @@ -23,7 +23,7 @@ type Documents = { "query ListHistoricalVotingPower($limit: PositiveInt, $skip: NonNegativeInt, $orderBy: queryInput_historicalVotingPower_orderBy, $orderDirection: queryInput_historicalVotingPower_orderDirection, $fromDate: String, $address: String) {\n historicalVotingPower(\n limit: $limit\n skip: $skip\n orderBy: $orderBy\n orderDirection: $orderDirection\n fromDate: $fromDate\n address: $address\n ) {\n items {\n accountId\n timestamp\n votingPower\n delta\n daoId\n transactionHash\n logIndex\n delegation {\n from\n to\n value\n previousDelegate\n }\n transfer {\n from\n to\n value\n }\n }\n totalCount\n }\n}": typeof types.ListHistoricalVotingPowerDocument, }; const documents: Documents = { - "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n }\n }\n}": types.GetDaOsDocument, + "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}": types.GetDaOsDocument, "query ListOffchainProposals($skip: NonNegativeInt, $limit: PositiveInt, $orderDirection: queryInput_offchainProposals_orderDirection, $status: JSON, $fromDate: Float) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n }\n totalCount\n }\n}": types.ListOffchainProposalsDocument, "query ProposalNonVoters($id: String!, $addresses: JSON) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": types.ProposalNonVotersDocument, "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, @@ -49,7 +49,7 @@ export function graphql(source: string): unknown; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export 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}"]; +export function graphql(source: "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}"): (typeof documents)["query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/anticapture-client/src/gql/graphql.ts b/packages/anticapture-client/src/gql/graphql.ts index cb88a415..2d66857c 100644 --- a/packages/anticapture-client/src/gql/graphql.ts +++ b/packages/anticapture-client/src/gql/graphql.ts @@ -721,6 +721,7 @@ export type CompareVotes_200_Response = { export type Dao_200_Response = { __typename?: 'dao_200_response'; + alreadySupportCalldataReview: Scalars['Boolean']['output']; chainId: Scalars['Float']['output']; id: Scalars['String']['output']; proposalThreshold: Scalars['String']['output']; @@ -1915,7 +1916,7 @@ export type VotingPowers_200_Response = { export type GetDaOsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetDaOsQuery = { __typename?: 'Query', daos: { __typename?: 'DAOList', items: Array<{ __typename?: 'dao_200_response', id: string, votingDelay: string, chainId: number }> } }; +export type GetDaOsQuery = { __typename?: 'Query', daos: { __typename?: 'DAOList', items: Array<{ __typename?: 'dao_200_response', id: string, votingDelay: string, chainId: number, alreadySupportCalldataReview: boolean }> } }; export type ListOffchainProposalsQueryVariables = Exact<{ skip?: InputMaybe; @@ -1991,7 +1992,7 @@ export type ListHistoricalVotingPowerQueryVariables = Exact<{ export type ListHistoricalVotingPowerQuery = { __typename?: 'Query', historicalVotingPower?: { __typename?: 'historicalVotingPower_200_response', totalCount: number, items: Array<{ __typename?: 'query_historicalVotingPower_items_items', accountId: string, timestamp: string, votingPower: string, delta: string, daoId: string, transactionHash: string, logIndex: number, delegation?: { __typename?: 'query_historicalVotingPower_items_items_delegation', from: string, to: string, value: string, previousDelegate?: string | null } | null, transfer?: { __typename?: 'query_historicalVotingPower_items_items_transfer', from: string, to: string, value: string } | null } | null> } | null }; -export const GetDaOsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDAOs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"daos"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"votingDelay"}},{"kind":"Field","name":{"kind":"Name","value":"chainId"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetDaOsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDAOs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"daos"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"votingDelay"}},{"kind":"Field","name":{"kind":"Name","value":"chainId"}},{"kind":"Field","name":{"kind":"Name","value":"alreadySupportCalldataReview"}}]}}]}}]}}]} as unknown as DocumentNode; export const ListOffchainProposalsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListOffchainProposals"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"skip"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NonNegativeInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PositiveInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"queryInput_offchainProposals_orderDirection"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"status"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromDate"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offchainProposals"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"skip"},"value":{"kind":"Variable","name":{"kind":"Name","value":"skip"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderDirection"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}}},{"kind":"Argument","name":{"kind":"Name","value":"status"},"value":{"kind":"Variable","name":{"kind":"Name","value":"status"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromDate"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromDate"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"discussion"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"created"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode; export const ProposalNonVotersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ProposalNonVoters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"addresses"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"proposalNonVoters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"addresses"},"value":{"kind":"Variable","name":{"kind":"Name","value":"addresses"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voter"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProposalByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProposalById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"proposal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"daoId"}},{"kind":"Field","name":{"kind":"Name","value":"proposerAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"startBlock"}},{"kind":"Field","name":{"kind":"Name","value":"endBlock"}},{"kind":"Field","name":{"kind":"Name","value":"endTimestamp"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"forVotes"}},{"kind":"Field","name":{"kind":"Name","value":"againstVotes"}},{"kind":"Field","name":{"kind":"Name","value":"abstainVotes"}},{"kind":"Field","name":{"kind":"Name","value":"txHash"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/anticapture-client/src/schemas.ts b/packages/anticapture-client/src/schemas.ts index 4efb81a0..bbd25f3b 100644 --- a/packages/anticapture-client/src/schemas.ts +++ b/packages/anticapture-client/src/schemas.ts @@ -11,7 +11,8 @@ export const SafeDaosResponseSchema = z.object({ items: z.array(z.object({ id: z.string(), votingDelay: z.string().optional(), - chainId: z.number() + chainId: z.number(), + alreadySupportCalldataReview: z.boolean().optional() })) }).nullable() }).transform((data) => { diff --git a/packages/anticapture-client/tests/anticapture-client.test.ts b/packages/anticapture-client/tests/anticapture-client.test.ts index fabe44ba..e15c1933 100644 --- a/packages/anticapture-client/tests/anticapture-client.test.ts +++ b/packages/anticapture-client/tests/anticapture-client.test.ts @@ -35,9 +35,9 @@ describe('AnticaptureClient', () => { const result = await client.getDAOs(); expect(result).toEqual([ - { id: 'UNISWAP', blockTime: 12, votingDelay: '1000' }, - { id: 'ENS', blockTime: 12, votingDelay: '500' }, - { id: 'COMPOUND', blockTime: 12, votingDelay: '0' } + { id: 'UNISWAP', blockTime: 12, votingDelay: '1000', chainId: undefined, alreadySupportCalldataReview: false }, + { id: 'ENS', blockTime: 12, votingDelay: '500', chainId: undefined, alreadySupportCalldataReview: false }, + { id: 'COMPOUND', blockTime: 12, votingDelay: '0', chainId: undefined, alreadySupportCalldataReview: false } ]); }); @@ -85,7 +85,8 @@ describe('AnticaptureClient', () => { id, blockTime: 12, votingDelay: '0', - chainId: 1 + chainId: 1, + alreadySupportCalldataReview: true })); jest.spyOn(client, 'getDAOs').mockResolvedValue(mockDAOs); }); @@ -185,7 +186,8 @@ describe('AnticaptureClient', () => { id, blockTime: 12, votingDelay: '0', - chainId: 1 + chainId: 1, + alreadySupportCalldataReview: true })); jest.spyOn(client, 'getDAOs').mockResolvedValue(mockDAOs); }); @@ -216,7 +218,8 @@ describe('AnticaptureClient', () => { id, blockTime: 12, votingDelay: '0', - chainId: 1 + chainId: 1, + alreadySupportCalldataReview: true })); jest.spyOn(client, 'getDAOs').mockResolvedValue(mockDAOs); diff --git a/packages/messages/src/triggers/buttons.ts b/packages/messages/src/triggers/buttons.ts index 7d7c8cd4..9b67e73a 100644 --- a/packages/messages/src/triggers/buttons.ts +++ b/packages/messages/src/triggers/buttons.ts @@ -93,6 +93,7 @@ export interface BuildButtonsParams { address?: string; proposalId?: string; proposalUrl?: string; + alreadySupportCalldataReview?: boolean; } const explorerService = new ExplorerService(); @@ -119,6 +120,14 @@ export function buildButtons(params: BuildButtonsParams): Button[] { buttons.push({ text: discussionButtonText, url: params.discussionUrl }); } + // Add calldata review button for new proposals when DAO doesn't natively support it + if (params.alreadySupportCalldataReview === false) { + const message = encodeURIComponent( + `Hi, I'd like to request a call-data review for proposal ${params.proposalId ?? 'unknown'} in ${params.daoId ?? 'unknown'}.` + ); + buttons.push({ text: '🔎 Request a call-data review', url: `https://t.me/Zeugh?text=${message}` }); + } + // Add scan button if transaction info is available if (params.txHash && params.chainId) { const scanUrl = explorerService.getTransactionLink(params.chainId, params.txHash);