|
1 | 1 | """ |
2 | 2 | Sync endpoints - fetch data from blockchain RPC and store in database. |
3 | 3 |
|
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. |
5 | 6 | Replaces the 24/7 indexer - now we fetch on-demand when user acts. |
6 | 7 |
|
7 | 8 | Endpoints: |
8 | 9 | POST /api/v1/campaigns/{campaign_id}/sync - Sync single campaign |
9 | 10 | 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 |
10 | 14 | """ |
11 | 15 | import base64 |
12 | 16 | import json |
@@ -106,6 +110,27 @@ def fetch_tx_result(tx_hash: str, sender_id: str): |
106 | 110 | return result.get("result") |
107 | 111 |
|
108 | 112 |
|
| 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 | + |
109 | 134 | def parse_donation_from_tx(tx_result: dict) -> dict: |
110 | 135 | """ |
111 | 136 | Parse donation data from transaction execution result. |
@@ -397,3 +422,228 @@ def post(self, request, campaign_id: int): |
397 | 422 | except Exception as e: |
398 | 423 | logger.error(f"Error syncing donation for campaign {campaign_id}: {e}") |
399 | 424 | 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