Skip to content

Commit c2d8990

Browse files
committed
Add diret donation sync for lists donations
1 parent db66701 commit c2d8990

File tree

2 files changed

+204
-0
lines changed

2 files changed

+204
-0
lines changed

api/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
CampaignDonationSyncAPI,
3737
)
3838
from donations.api import DonationContractConfigAPI
39+
from donations.sync import DirectDonationSyncAPI
3940
from grantpicks.api import AccountProjectListAPI, ProjectListAPI, ProjectRoundVotesAPI, ProjectStatsAPI, RoundApplicationsAPI, RoundDetailAPI, RoundsListAPI
4041
from lists.api import (
4142
ListDetailAPI,
@@ -119,6 +120,12 @@
119120
DonationContractConfigAPI.as_view(),
120121
name="donate_contract_config_api",
121122
),
123+
# direct donation sync
124+
path(
125+
"v1/donations/sync",
126+
DirectDonationSyncAPI.as_view(),
127+
name="direct_donation_sync_api",
128+
),
122129
# campaigns
123130
path("v1/campaigns", CampaignsAPI.as_view(), name="campaigns_api"),
124131
path(

donations/sync.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""
2+
Sync endpoints for direct donations - fetch data from blockchain RPC and store in database.
3+
4+
Called by frontend after user makes a direct donation.
5+
6+
Endpoints:
7+
POST /api/v1/donations/sync - Sync single donation via tx_hash
8+
"""
9+
import base64
10+
import json
11+
import logging
12+
from datetime import datetime, timezone
13+
14+
import requests
15+
from django.conf import settings
16+
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
17+
from rest_framework.response import Response
18+
from rest_framework.views import APIView
19+
20+
from accounts.models import Account
21+
from donations.models import Donation
22+
from tokens.models import Token
23+
24+
logger = logging.getLogger(__name__)
25+
26+
DONATION_CONTRACT = f"donate.{settings.POTLOCK_TLA}"
27+
28+
29+
def fetch_tx_result(tx_hash: str, sender_id: str):
30+
"""
31+
Fetch transaction result from NEAR RPC.
32+
Returns the parsed result from the transaction execution.
33+
"""
34+
rpc_url = (
35+
"https://test.rpc.fastnear.com"
36+
if settings.ENVIRONMENT == "testnet"
37+
else "https://free.rpc.fastnear.com"
38+
)
39+
40+
payload = {
41+
"jsonrpc": "2.0",
42+
"id": "dontcare",
43+
"method": "tx",
44+
"params": {
45+
"tx_hash": tx_hash,
46+
"sender_account_id": sender_id,
47+
"wait_until": "EXECUTED_OPTIMISTIC",
48+
},
49+
}
50+
51+
response = requests.post(rpc_url, json=payload, timeout=30)
52+
result = response.json()
53+
54+
if "error" in result:
55+
raise Exception(f"RPC error fetching tx: {result['error']}")
56+
57+
return result.get("result")
58+
59+
60+
def parse_donation_from_tx(tx_result: dict) -> dict:
61+
"""
62+
Parse donation data from transaction execution result.
63+
Looks through receipts_outcome to find the SuccessValue containing donation data.
64+
"""
65+
receipts_outcome = tx_result.get("receipts_outcome", [])
66+
67+
for outcome in receipts_outcome:
68+
status = outcome.get("outcome", {}).get("status", {})
69+
if isinstance(status, dict) and "SuccessValue" in status:
70+
success_value = status["SuccessValue"]
71+
if success_value:
72+
try:
73+
decoded = base64.b64decode(success_value).decode()
74+
data = json.loads(decoded)
75+
# Check if this looks like direct donation data
76+
if isinstance(data, dict) and "donor_id" in data and "recipient_id" in data:
77+
return data
78+
except (json.JSONDecodeError, UnicodeDecodeError):
79+
continue
80+
81+
return None
82+
83+
84+
class DirectDonationSyncAPI(APIView):
85+
"""
86+
Sync a direct donation from blockchain to database.
87+
88+
Called by frontend after a user makes a direct donation.
89+
Frontend passes the transaction hash, backend parses the donation from tx result.
90+
"""
91+
92+
@extend_schema(
93+
summary="Sync a direct donation",
94+
description="Sync a single direct donation using the transaction hash from the donation response.",
95+
parameters=[
96+
OpenApiParameter(
97+
name="tx_hash",
98+
description="Transaction hash from the donation transaction",
99+
required=True,
100+
type=str,
101+
),
102+
OpenApiParameter(
103+
name="sender_id",
104+
description="Account ID of the transaction sender (donor)",
105+
required=True,
106+
type=str,
107+
),
108+
],
109+
responses={
110+
200: OpenApiResponse(description="Donation synced"),
111+
400: OpenApiResponse(description="Missing required parameters"),
112+
404: OpenApiResponse(description="Donation not found in transaction"),
113+
502: OpenApiResponse(description="RPC failed"),
114+
},
115+
)
116+
def post(self, request):
117+
try:
118+
# Get required parameters
119+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
120+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
121+
122+
if not tx_hash or not sender_id:
123+
return Response(
124+
{"error": "tx_hash and sender_id are required"},
125+
status=400,
126+
)
127+
128+
# Fetch transaction result and parse donation data
129+
tx_result = fetch_tx_result(tx_hash, sender_id)
130+
if not tx_result:
131+
return Response({"error": "Transaction not found"}, status=404)
132+
133+
donation_data = parse_donation_from_tx(tx_result)
134+
if not donation_data:
135+
return Response(
136+
{"error": "Could not parse donation from transaction result"},
137+
status=404,
138+
)
139+
140+
# Upsert accounts
141+
donor, _ = Account.objects.get_or_create(
142+
defaults={"chain_id": 1}, id=donation_data["donor_id"]
143+
)
144+
recipient, _ = Account.objects.get_or_create(
145+
defaults={"chain_id": 1}, id=donation_data["recipient_id"]
146+
)
147+
148+
referrer = None
149+
if donation_data.get("referrer_id"):
150+
referrer, _ = Account.objects.get_or_create(
151+
defaults={"chain_id": 1}, id=donation_data["referrer_id"]
152+
)
153+
154+
# Get or create token
155+
token_id = donation_data.get("ft_id") or "near"
156+
token_acct, _ = Account.objects.get_or_create(defaults={"chain_id": 1}, id=token_id)
157+
token, _ = Token.objects.get_or_create(account=token_acct, defaults={"decimals": 24})
158+
159+
# Parse timestamp
160+
donated_at = datetime.fromtimestamp(
161+
donation_data["donated_at_ms"] / 1000, tz=timezone.utc
162+
)
163+
164+
# Create or update donation
165+
donation_defaults = {
166+
"donor": donor,
167+
"recipient": recipient,
168+
"token": token,
169+
"total_amount": str(donation_data["total_amount"]),
170+
"net_amount": str(donation_data["net_amount"]),
171+
"message": donation_data.get("message"),
172+
"donated_at": donated_at,
173+
"protocol_fee": str(donation_data.get("protocol_fee", "0")),
174+
"referrer": referrer,
175+
"referrer_fee": str(donation_data["referrer_fee"]) if donation_data.get("referrer_fee") else None,
176+
"matching_pool": False,
177+
"tx_hash": tx_hash,
178+
}
179+
180+
donation, created = Donation.objects.update_or_create(
181+
on_chain_id=donation_data["id"],
182+
pot__isnull=True, # Direct donations have no pot
183+
defaults=donation_defaults,
184+
)
185+
186+
return Response(
187+
{
188+
"success": True,
189+
"message": "Donation synced",
190+
"donation_id": donation.on_chain_id,
191+
"created": created,
192+
}
193+
)
194+
195+
except Exception as e:
196+
logger.error(f"Error syncing direct donation: {e}")
197+
return Response({"error": str(e)}, status=502)

0 commit comments

Comments
 (0)