Skip to content

Commit bca26f1

Browse files
authored
Merge pull request #195 from PotLock/testnet
Testnet
2 parents aeeb3e9 + cd4b50e commit bca26f1

File tree

2 files changed

+269
-1
lines changed

2 files changed

+269
-1
lines changed

api/urls.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
from campaigns.sync import (
4040
CampaignSyncAPI,
4141
CampaignDonationSyncAPI,
42+
CampaignDeleteSyncAPI,
43+
CampaignRefundSyncAPI,
44+
CampaignUnescrowSyncAPI,
4245
)
4346
from donations.api import DonationContractConfigAPI
4447
from donations.sync import DirectDonationSyncAPI
@@ -164,6 +167,21 @@
164167
CampaignDonationSyncAPI.as_view(),
165168
name="campaign_donation_sync_api",
166169
),
170+
path(
171+
"v1/campaigns/<int:campaign_id>/delete/sync",
172+
CampaignDeleteSyncAPI.as_view(),
173+
name="campaign_delete_sync_api",
174+
),
175+
path(
176+
"v1/campaigns/<int:campaign_id>/refunds/sync",
177+
CampaignRefundSyncAPI.as_view(),
178+
name="campaign_refund_sync_api",
179+
),
180+
path(
181+
"v1/campaigns/<int:campaign_id>/unescrow/sync",
182+
CampaignUnescrowSyncAPI.as_view(),
183+
name="campaign_unescrow_sync_api",
184+
),
167185
# donors
168186
path("v1/donors", DonorsAPI.as_view(), name="donors_api"),
169187
# lists

campaigns/sync.py

Lines changed: 251 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""
22
Sync endpoints - fetch data from blockchain RPC and store in database.
33
4-
Called by frontend after user creates/updates a campaign or donates.
4+
Called by frontend after user creates/updates/deletes a campaign, donates,
5+
or processes refunds/escrowed donations.
56
Replaces the 24/7 indexer - now we fetch on-demand when user acts.
67
78
Endpoints:
89
POST /api/v1/campaigns/{campaign_id}/sync - Sync single campaign
910
POST /api/v1/campaigns/{campaign_id}/donations/sync - Sync single donation via tx_hash
11+
POST /api/v1/campaigns/{campaign_id}/delete/sync - Sync campaign deletion via tx_hash
12+
POST /api/v1/campaigns/{campaign_id}/refunds/sync - Sync donation refunds via tx_hash
13+
POST /api/v1/campaigns/{campaign_id}/unescrow/sync - Sync donation unescrow via tx_hash
1014
"""
1115
import base64
1216
import json
@@ -106,6 +110,27 @@ def fetch_tx_result(tx_hash: str, sender_id: str):
106110
return result.get("result")
107111

108112

113+
def parse_events_from_tx(tx_result: dict, event_name: str) -> list[dict]:
114+
115+
events = []
116+
receipts_outcome = tx_result.get("receipts_outcome", [])
117+
118+
for outcome in receipts_outcome:
119+
logs = outcome.get("outcome", {}).get("logs", [])
120+
for log in logs:
121+
if not log.startswith("EVENT_JSON:"):
122+
continue
123+
try:
124+
parsed = json.loads(log[len("EVENT_JSON:"):])
125+
if parsed.get("event") == event_name:
126+
for data_item in parsed.get("data", []):
127+
events.append(data_item)
128+
except (json.JSONDecodeError, KeyError):
129+
continue
130+
131+
return events
132+
133+
109134
def parse_donation_from_tx(tx_result: dict) -> dict:
110135
"""
111136
Parse donation data from transaction execution result.
@@ -397,3 +422,228 @@ def post(self, request, campaign_id: int):
397422
except Exception as e:
398423
logger.error(f"Error syncing donation for campaign {campaign_id}: {e}")
399424
return Response({"error": str(e)}, status=502)
425+
426+
427+
class CampaignDeleteSyncAPI(APIView):
428+
429+
430+
@extend_schema(
431+
summary="Sync campaign deletion from blockchain via tx_hash",
432+
parameters=[
433+
OpenApiParameter(name="tx_hash", required=True, type=str),
434+
OpenApiParameter(name="sender_id", required=True, type=str),
435+
],
436+
responses={
437+
200: OpenApiResponse(description="Campaign deleted from DB"),
438+
400: OpenApiResponse(description="Missing parameters or no delete event found"),
439+
404: OpenApiResponse(description="Campaign not found in DB"),
440+
502: OpenApiResponse(description="RPC failed"),
441+
},
442+
)
443+
def post(self, request, campaign_id: int):
444+
try:
445+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
446+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
447+
448+
if not tx_hash or not sender_id:
449+
return Response(
450+
{"error": "tx_hash and sender_id are required"},
451+
status=400,
452+
)
453+
454+
# Fetch transaction and parse campaign_delete events
455+
tx_result = fetch_tx_result(tx_hash, sender_id)
456+
if not tx_result:
457+
return Response({"error": "Transaction not found"}, status=404)
458+
459+
delete_events = parse_events_from_tx(tx_result, "campaign_delete")
460+
461+
# Find the delete event for this specific campaign
462+
matching_event = None
463+
for event in delete_events:
464+
if event.get("campaign_id") == int(campaign_id):
465+
matching_event = event
466+
break
467+
468+
if not matching_event:
469+
return Response(
470+
{"error": f"No campaign_delete event found for campaign {campaign_id} in this transaction"},
471+
status=400,
472+
)
473+
474+
# Event verified — delete from DB
475+
deleted_count, _ = Campaign.objects.filter(on_chain_id=int(campaign_id)).delete()
476+
477+
if deleted_count > 0:
478+
logger.info(f"Campaign {campaign_id} deleted from DB (verified via tx {tx_hash})")
479+
return Response(
480+
{
481+
"success": True,
482+
"message": "Campaign deleted",
483+
"on_chain_id": campaign_id,
484+
}
485+
)
486+
487+
return Response({"error": "Campaign not found in database"}, status=404)
488+
489+
except Exception as e:
490+
logger.error(f"Error syncing campaign deletion {campaign_id}: {e}")
491+
return Response({"error": str(e)}, status=502)
492+
493+
494+
class CampaignRefundSyncAPI(APIView):
495+
496+
@extend_schema(
497+
summary="Sync donation refunds from blockchain via tx_hash",
498+
parameters=[
499+
OpenApiParameter(name="tx_hash", required=True, type=str),
500+
OpenApiParameter(name="sender_id", required=True, type=str),
501+
],
502+
responses={
503+
200: OpenApiResponse(description="Refunds synced"),
504+
400: OpenApiResponse(description="Missing parameters or no refund event found"),
505+
502: OpenApiResponse(description="RPC failed"),
506+
},
507+
)
508+
def post(self, request, campaign_id: int):
509+
try:
510+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
511+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
512+
513+
if not tx_hash or not sender_id:
514+
return Response(
515+
{"error": "tx_hash and sender_id are required"},
516+
status=400,
517+
)
518+
519+
tx_result = fetch_tx_result(tx_hash, sender_id)
520+
if not tx_result:
521+
return Response({"error": "Transaction not found"}, status=404)
522+
523+
refund_events = parse_events_from_tx(tx_result, "escrow_refund")
524+
525+
# Find refund events for this campaign
526+
matching_events = [
527+
e for e in refund_events if e.get("campaign_id") == int(campaign_id)
528+
]
529+
530+
if not matching_events:
531+
return Response(
532+
{"error": f"No escrow_refund event found for campaign {campaign_id} in this transaction"},
533+
status=400,
534+
)
535+
536+
total_refunded = 0
537+
now = datetime.now(tz=timezone.utc)
538+
539+
for event_data in matching_events:
540+
donation_ids = event_data.get("donations", [])
541+
542+
# Mark donations as refunded (mirrors handle_campaign_donation_refund)
543+
updated_count = CampaignDonation.objects.filter(
544+
on_chain_id__in=donation_ids, campaign__on_chain_id=int(campaign_id)
545+
).update(returned_at=now)
546+
547+
total_refunded += updated_count
548+
549+
# Update campaign escrow balance and totals
550+
try:
551+
campaign = Campaign.objects.get(on_chain_id=int(campaign_id))
552+
escrow_balance = event_data.get("escrow_balance", "0")
553+
campaign.escrow_balance = str(
554+
int(campaign.escrow_balance) - int(escrow_balance)
555+
)
556+
557+
refunded_donations = CampaignDonation.objects.filter(
558+
on_chain_id__in=donation_ids, campaign__on_chain_id=int(campaign_id)
559+
).values_list("total_amount", "net_amount")
560+
561+
total_amount_refunded = sum(int(d[0]) for d in refunded_donations)
562+
net_amount_refunded = sum(int(d[1]) for d in refunded_donations)
563+
564+
campaign.total_raised_amount = str(
565+
int(campaign.total_raised_amount) - total_amount_refunded
566+
)
567+
campaign.net_raised_amount = str(
568+
int(campaign.net_raised_amount) - net_amount_refunded
569+
)
570+
campaign.save()
571+
572+
except Campaign.DoesNotExist:
573+
logger.error(f"Campaign {campaign_id} not found for refund update")
574+
575+
logger.info(f"Synced {total_refunded} refunds for campaign {campaign_id} (tx {tx_hash})")
576+
return Response(
577+
{
578+
"success": True,
579+
"message": f"{total_refunded} donation(s) marked as refunded",
580+
"refunded_count": total_refunded,
581+
}
582+
)
583+
584+
except Exception as e:
585+
logger.error(f"Error syncing refunds for campaign {campaign_id}: {e}")
586+
return Response({"error": str(e)}, status=502)
587+
588+
589+
class CampaignUnescrowSyncAPI(APIView):
590+
591+
@extend_schema(
592+
summary="Sync donation unescrow from blockchain via tx_hash",
593+
parameters=[
594+
OpenApiParameter(name="tx_hash", required=True, type=str),
595+
OpenApiParameter(name="sender_id", required=True, type=str),
596+
],
597+
responses={
598+
200: OpenApiResponse(description="Unescrow synced"),
599+
400: OpenApiResponse(description="Missing parameters or no unescrow event found"),
600+
502: OpenApiResponse(description="RPC failed"),
601+
},
602+
)
603+
def post(self, request, campaign_id: int):
604+
try:
605+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
606+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
607+
608+
if not tx_hash or not sender_id:
609+
return Response(
610+
{"error": "tx_hash and sender_id are required"},
611+
status=400,
612+
)
613+
614+
tx_result = fetch_tx_result(tx_hash, sender_id)
615+
if not tx_result:
616+
return Response({"error": "Transaction not found"}, status=404)
617+
618+
unescrow_events = parse_events_from_tx(tx_result, "escrow_process")
619+
620+
if not unescrow_events:
621+
return Response(
622+
{"error": "No escrow_process event found in this transaction"},
623+
status=400,
624+
)
625+
626+
total_unescrowed = 0
627+
628+
for event_data in unescrow_events:
629+
donation_ids = event_data.get("donation_ids", [])
630+
631+
# Mark donations as unescrowed (mirrors handle_campaign_donation_unescrowed)
632+
updated_count = CampaignDonation.objects.filter(
633+
on_chain_id__in=donation_ids
634+
).update(escrowed=False)
635+
636+
total_unescrowed += updated_count
637+
638+
logger.info(f"Synced {total_unescrowed} unescrows for campaign {campaign_id} (tx {tx_hash})")
639+
return Response(
640+
{
641+
"success": True,
642+
"message": f"{total_unescrowed} donation(s) marked as unescrowed",
643+
"unescrowed_count": total_unescrowed,
644+
}
645+
)
646+
647+
except Exception as e:
648+
logger.error(f"Error syncing unescrow for campaign {campaign_id}: {e}")
649+
return Response({"error": str(e)}, status=502)

0 commit comments

Comments
 (0)