@@ -36,6 +36,9 @@ def __init__(self):
3636 super ().__init__ ()
3737 self .logger = configure_logger (self .__class__ .__name__ )
3838 self .chainhook_data : Optional [ChainHookData ] = None
39+ self ._processed_burn_heights : set = (
40+ set ()
41+ ) # Track processed burn heights in this session
3942
4043 def set_chainhook_data (self , data : ChainHookData ) -> None :
4144 """Set the chainhook data for this handler.
@@ -134,7 +137,7 @@ def _queue_message_exists(
134137 filters = QueueMessageFilter (
135138 type = message_type ,
136139 dao_id = dao_id ,
137- is_processed = False ,
140+ # Check both processed and unprocessed to prevent duplicates
138141 )
139142
140143 if wallet_id :
@@ -148,6 +151,44 @@ def _queue_message_exists(
148151 for msg in existing_messages
149152 )
150153
154+ def _discord_message_exists (
155+ self , proposal_id : UUID , dao_id : UUID , proposal_status : str
156+ ) -> bool :
157+ """Check if a Discord message already exists for this proposal and status.
158+
159+ Args:
160+ proposal_id: The proposal ID
161+ dao_id: The DAO ID
162+ proposal_status: The specific proposal status (e.g., 'veto_window_open')
163+
164+ Returns:
165+ bool: True if message exists, False otherwise
166+ """
167+ filters = QueueMessageFilter (
168+ type = QueueMessageType .get_or_create ("discord" ),
169+ dao_id = dao_id ,
170+ # Check both processed and unprocessed to prevent duplicates
171+ )
172+
173+ existing_messages = backend .list_queue_messages (filters = filters )
174+
175+ # Create a unique identifier for this specific message type
176+ unique_identifier = f"proposal-{ proposal_id } -{ proposal_status } "
177+
178+ # Check for existing messages with matching proposal status and proposal reference
179+ return any (
180+ msg .message
181+ and msg .message .get ("proposal_status" ) == proposal_status
182+ and (
183+ # Check for unique identifier in content
184+ unique_identifier in str (msg .message .get ("content" , "" ))
185+ or
186+ # Fallback: check for proposal ID in content
187+ str (proposal_id ) in str (msg .message .get ("content" , "" ))
188+ )
189+ for msg in existing_messages
190+ )
191+
151192 async def handle_transaction (self , transaction : TransactionWithReceipt ) -> None :
152193 """Handle burn height check transactions.
153194
@@ -158,100 +199,136 @@ async def handle_transaction(self, transaction: TransactionWithReceipt) -> None:
158199 Args:
159200 transaction: The transaction to handle
160201 """
161- tx_data = self .extract_transaction_data (transaction )
162- burn_height = self ._get_burn_height (tx_data )
202+ try :
203+ tx_data = self .extract_transaction_data (transaction )
204+ burn_height = self ._get_burn_height (tx_data )
163205
164- if burn_height is None :
165- self .logger .warning ("Could not determine burn height from transaction" )
166- return
206+ if burn_height is None :
207+ self .logger .warning ("Could not determine burn height from transaction" )
208+ return
167209
168- self .logger .info (f"Processing burn height: { burn_height } " )
169-
170- # Find proposals that should start at this height
171- proposals = backend .list_proposals (
172- filters = ProposalFilter (
173- status = ContractStatus .DEPLOYED ,
174- )
175- )
210+ # Skip if we've already processed this burn height in this session
211+ burn_height_key = f"burn_{ burn_height } "
212+ if burn_height_key in self ._processed_burn_heights :
213+ self .logger .debug (
214+ f"Burn height { burn_height } already processed in this session, skipping"
215+ )
216+ return
176217
177- # Filter proposals that should start or end at this burn height
178- vote_proposals = [
179- p
180- for p in proposals
181- if p .vote_start is not None
182- and p .vote_end is not None
183- and p .vote_start == burn_height
184- and p .content is not None # Ensure content exists
185- ]
218+ self .logger .info (f"Processing burn height: { burn_height } " )
186219
187- end_proposals = [
188- p
189- for p in proposals
190- if p .vote_start is not None
191- and p .exec_start is not None
192- and p .exec_start == burn_height
193- and p .content is not None # Ensure content exists
194- ]
220+ # Mark this burn height as being processed
221+ self ._processed_burn_heights .add (burn_height_key )
195222
196- # Add veto window proposals
197- veto_start_proposals = [
198- p
199- for p in proposals
200- if p .vote_end is not None
201- and p .vote_end == burn_height
202- and p .content is not None
203- ]
223+ # Find proposals that should start at this height
224+ proposals = backend .list_proposals (
225+ filters = ProposalFilter (
226+ status = ContractStatus .DEPLOYED ,
227+ )
228+ )
204229
205- veto_end_proposals = [
206- p
207- for p in proposals
208- if p .exec_start is not None
209- and p .exec_start == burn_height
210- and p .content is not None
211- ]
230+ # Filter proposals that should start or end at this burn height
231+ vote_proposals = [
232+ p
233+ for p in proposals
234+ if p .vote_start is not None
235+ and p .vote_end is not None
236+ and p .vote_start == burn_height
237+ and p .content is not None # Ensure content exists
238+ ]
239+
240+ end_proposals = [
241+ p
242+ for p in proposals
243+ if p .vote_start is not None
244+ and p .exec_start is not None
245+ and p .exec_start == burn_height
246+ and p .content is not None # Ensure content exists
247+ ]
248+
249+ # Add veto window proposals
250+ veto_start_proposals = [
251+ p
252+ for p in proposals
253+ if p .vote_end is not None
254+ and p .vote_end == burn_height
255+ and p .content is not None
256+ ]
257+
258+ veto_end_proposals = [
259+ p
260+ for p in proposals
261+ if p .exec_start is not None
262+ and p .exec_start == burn_height
263+ and p .content is not None
264+ ]
265+
266+ if not (
267+ vote_proposals
268+ or end_proposals
269+ or veto_start_proposals
270+ or veto_end_proposals
271+ ):
272+ self .logger .debug (
273+ f"No eligible proposals found for burn height { burn_height } "
274+ )
275+ return
212276
213- if not (
214- vote_proposals
215- or end_proposals
216- or veto_start_proposals
217- or veto_end_proposals
218- ):
219277 self .logger .info (
220- f"No eligible proposals found for burn height { burn_height } "
278+ f"Found { len (vote_proposals )} proposals to vote, "
279+ f"{ len (end_proposals )} proposals to conclude, "
280+ f"{ len (veto_start_proposals )} proposals entering veto window, "
281+ f"{ len (veto_end_proposals )} proposals ending veto window"
221282 )
222- return
223283
224- self .logger .info (
225- f"Found { len (vote_proposals )} proposals to vote, "
226- f"{ len (end_proposals )} proposals to conclude, "
227- f"{ len (veto_start_proposals )} proposals entering veto window, "
228- f"{ len (veto_end_proposals )} proposals ending veto window"
229- )
284+ # Process veto window start notifications
285+ self ._process_veto_window_start_notifications (veto_start_proposals )
286+
287+ # Process veto window end notifications
288+ self ._process_veto_window_end_notifications (veto_end_proposals )
289+
290+ # Process proposals that are ending
291+ self ._process_ending_proposals (end_proposals )
292+
293+ # Process proposals that are ready for voting
294+ self ._process_voting_proposals (vote_proposals )
295+
296+ except Exception as e :
297+ self .logger .error (
298+ f"Error processing transaction in DAOProposalBurnHeightHandler: { str (e )} " ,
299+ exc_info = True ,
300+ )
301+ # Remove the burn height from processed set on error to allow retry
302+ if "burn_height_key" in locals ():
303+ self ._processed_burn_heights .discard (burn_height_key )
230304
231- # Process veto window start notifications
305+ def _process_veto_window_start_notifications (self , veto_start_proposals ):
306+ """Process veto window start notifications."""
232307 for proposal in veto_start_proposals :
233308 dao = backend .get_dao (proposal .dao_id )
234309 if not dao :
235310 self .logger .warning (f"No DAO found for proposal { proposal .id } " )
236311 continue
237312
238- # Check if a veto notification message already exists
239- if self ._queue_message_exists (
240- QueueMessageType .get_or_create ("discord" ), proposal .id , dao .id
241- ):
313+ # Check if a veto window start Discord message already exists
314+ if self ._discord_message_exists (proposal .id , dao .id , "veto_window_open" ):
242315 self .logger .debug (
243- f"Veto notification Discord message already exists for proposal { proposal .id } , skipping"
316+ f"Veto window start Discord message already exists for proposal { proposal .id } , skipping"
244317 )
245318 continue
246319
320+ # Create unique identifier for this message
321+ unique_identifier = f"proposal-{ proposal .id } -veto_window_open"
322+
247323 # Create veto window start Discord message
248324 message = (
249325 f"⚠️ **VETO WINDOW OPEN: Proposal #{ proposal .proposal_id } of { dao .name } **\n \n "
250326 f"**Proposal:**\n { proposal .content [:100 ]} ...\n \n "
251327 f"**Veto Window Details:**\n "
252328 f"• Opens at: Block { proposal .vote_end } \n "
253329 f"• Closes at: Block { proposal .exec_start } \n \n "
254- f"View proposal details: { config .api .base_url } /proposals/{ proposal .id } "
330+ f"View proposal details: { config .api .base_url } /proposals/{ proposal .id } \n \n "
331+ f"<!-- { unique_identifier } -->"
255332 )
256333
257334 backend .create_queue_message (
@@ -265,30 +342,33 @@ async def handle_transaction(self, transaction: TransactionWithReceipt) -> None:
265342 f"Created veto window start Discord message for proposal { proposal .id } "
266343 )
267344
268- # Process veto window end notifications
345+ def _process_veto_window_end_notifications (self , veto_end_proposals ):
346+ """Process veto window end notifications."""
269347 for proposal in veto_end_proposals :
270348 dao = backend .get_dao (proposal .dao_id )
271349 if not dao :
272350 self .logger .warning (f"No DAO found for proposal { proposal .id } " )
273351 continue
274352
275- # Check if a veto end notification message already exists
276- if self ._queue_message_exists (
277- QueueMessageType .get_or_create ("discord" ), proposal .id , dao .id
278- ):
353+ # Check if a veto window end Discord message already exists
354+ if self ._discord_message_exists (proposal .id , dao .id , "veto_window_closed" ):
279355 self .logger .debug (
280- f"Veto end notification Discord message already exists for proposal { proposal .id } , skipping"
356+ f"Veto window end Discord message already exists for proposal { proposal .id } , skipping"
281357 )
282358 continue
283359
360+ # Create unique identifier for this message
361+ unique_identifier = f"proposal-{ proposal .id } -veto_window_closed"
362+
284363 # Create veto window end Discord message
285364 message = (
286365 f"🔒 **VETO WINDOW CLOSED: Proposal #{ proposal .proposal_id } of { dao .name } **\n \n "
287366 f"**Proposal:**\n { proposal .content [:100 ]} ...\n \n "
288367 f"**Status:**\n "
289368 f"• Veto window has now closed\n "
290369 f"• Proposal will be executed if it passed voting\n \n "
291- f"View proposal details: { config .api .base_url } /proposals/{ proposal .id } "
370+ f"View proposal details: { config .api .base_url } /proposals/{ proposal .id } \n \n "
371+ f"<!-- { unique_identifier } -->"
292372 )
293373
294374 backend .create_queue_message (
@@ -305,7 +385,8 @@ async def handle_transaction(self, transaction: TransactionWithReceipt) -> None:
305385 f"Created veto window end Discord message for proposal { proposal .id } "
306386 )
307387
308- # Process proposals that are ending
388+ def _process_ending_proposals (self , end_proposals ):
389+ """Process proposals that are ending."""
309390 for proposal in end_proposals :
310391 dao = backend .get_dao (proposal .dao_id )
311392 if not dao :
@@ -341,7 +422,8 @@ async def handle_transaction(self, transaction: TransactionWithReceipt) -> None:
341422 f"Created conclude queue message for proposal { proposal .id } "
342423 )
343424
344- # Process proposals that are ready for voting
425+ def _process_voting_proposals (self , vote_proposals ):
426+ """Process proposals that are ready for voting."""
345427 for proposal in vote_proposals :
346428 # Get the DAO for this proposal
347429 dao = backend .get_dao (proposal .dao_id )
0 commit comments