Skip to content

Commit 19be985

Browse files
authored
feat: add SC-38 structured event support (#1188)
1 parent dcc8c86 commit 19be985

29 files changed

Lines changed: 970 additions & 19 deletions

django-backend/soroscan/ingest/serializers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,17 @@ class RecordEventRequestSerializer(serializers.Serializer):
567567
)
568568

569569

570+
class StructuredEventRequestSerializer(RecordEventRequestSerializer):
571+
"""SC-38 request payload for versioned, idempotent contract events."""
572+
573+
schema_version = serializers.IntegerField(min_value=1, help_text="Payload schema version")
574+
correlation_id = serializers.CharField(
575+
max_length=64,
576+
min_length=64,
577+
help_text="64-character hexadecimal id used to deduplicate retries",
578+
)
579+
580+
570581
class APIKeySerializer(serializers.ModelSerializer):
571582
"""
572583
Serializer for APIKey model.

django-backend/soroscan/ingest/stellar_client.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,61 @@ def record_event(
240240
error=str(e),
241241
)
242242

243+
def record_structured_event(
244+
self,
245+
target_contract_id: str,
246+
event_type: str,
247+
payload_hash_hex: str,
248+
schema_version: int,
249+
correlation_id_hex: str,
250+
) -> TransactionResult:
251+
"""Submit the SC-38 versioned and correlation-safe event invocation."""
252+
if not self.keypair:
253+
return TransactionResult(False, "", "error", error="No keypair configured")
254+
255+
try:
256+
payload_hash = bytes.fromhex(payload_hash_hex)
257+
correlation_id = bytes.fromhex(correlation_id_hex)
258+
if len(payload_hash) != 32 or len(correlation_id) != 32:
259+
raise ValueError("Payload hash and correlation ID must be 32 bytes")
260+
account = self.server.load_account(self.keypair.public_key)
261+
tx = (
262+
TransactionBuilder(
263+
source_account=account,
264+
network_passphrase=self.network_passphrase,
265+
base_fee=100000,
266+
)
267+
.append_invoke_contract_function_op(
268+
contract_id=self.contract_id,
269+
function_name="record_structured_event",
270+
parameters=[
271+
self._address_to_sc_val(self.keypair.public_key),
272+
self._address_to_sc_val(target_contract_id),
273+
self._symbol_to_sc_val(event_type),
274+
self._bytes_to_sc_val(payload_hash),
275+
SCVal(type=SCValType.SCV_U32, u32=schema_version),
276+
self._bytes_to_sc_val(correlation_id),
277+
],
278+
)
279+
.set_timeout(30)
280+
.build()
281+
)
282+
simulation = self.server.simulate_transaction(tx)
283+
if simulation.error:
284+
return TransactionResult(False, "", "simulation_failed", error=simulation.error)
285+
prepared = self.server.prepare_transaction(tx, simulation)
286+
prepared.sign(self.keypair)
287+
response = self.server.send_transaction(prepared)
288+
return TransactionResult(
289+
success=response.status == "PENDING",
290+
tx_hash=response.hash,
291+
status=response.status,
292+
result_xdr=getattr(response, "result_xdr", None),
293+
)
294+
except Exception as e:
295+
logger.exception("Failed to record SC-38 structured event")
296+
return TransactionResult(False, "", "error", error=str(e))
297+
243298
def get_total_events(self) -> Optional[int]:
244299
"""
245300
Query the total_events function on the contract.

django-backend/soroscan/ingest/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
health_check,
2929
networks_view,
3030
record_event_view,
31+
record_structured_event_view,
3132
restore_archived_events,
3233
transaction_events_view,
3334
vulnerability_impact_view,
@@ -84,6 +85,7 @@
8485
),
8586
path("", include(router.urls)),
8687
path("record/", record_event_view, name="record-event"),
88+
path("record/structured/", record_structured_event_view, name="record-structured-event"),
8789
path("health/", health_check, name="health-check"),
8890
path("events/type-statistics/", event_type_statistics_view, name="event-type-statistics"),
8991
path("events/restore-archive/", restore_archived_events, name="restore-archive"),

django-backend/soroscan/ingest/views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
OrganizationCorsSerializer,
6868
OrganizationCostSnapshotSerializer,
6969
RecordEventRequestSerializer,
70+
StructuredEventRequestSerializer,
7071
TeamMemberAddSerializer,
7172
TeamSerializer,
7273
TrackedContractSerializer,
@@ -1028,6 +1029,39 @@ def record_event_view(request):
10281029
)
10291030

10301031

1032+
@extend_schema(request=StructuredEventRequestSerializer)
1033+
@api_view(["POST"])
1034+
@permission_classes([IsAuthenticated])
1035+
@throttle_classes([IngestRateThrottle, AnonRateThrottle, UserRateThrottle])
1036+
def record_structured_event_view(request):
1037+
"""Relay a versioned, deduplicated SC-38 event to the core contract."""
1038+
serializer = StructuredEventRequestSerializer(data=request.data)
1039+
if not serializer.is_valid():
1040+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
1041+
1042+
data = serializer.validated_data
1043+
result = SorobanClient().record_structured_event(
1044+
target_contract_id=data["contract_id"],
1045+
event_type=data["event_type"],
1046+
payload_hash_hex=data["payload_hash"],
1047+
schema_version=data["schema_version"],
1048+
correlation_id_hex=data["correlation_id"],
1049+
)
1050+
if result.success:
1051+
return Response(
1052+
{
1053+
"status": "submitted",
1054+
"tx_hash": result.tx_hash,
1055+
"transaction_status": result.status,
1056+
},
1057+
status=status.HTTP_202_ACCEPTED,
1058+
)
1059+
return Response(
1060+
{"status": "failed", "error": result.error, "transaction_status": result.status},
1061+
status=status.HTTP_400_BAD_REQUEST,
1062+
)
1063+
1064+
10311065
@extend_schema(
10321066
responses=inline_serializer(
10331067
name="WebhookSigningPublicKeyResponse",

sdk/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# SoroScan SDKs
22

3+
## SC-38 structured events
4+
5+
SC-38 adds a versioned, retry-safe event submission path. Provide a SHA-256
6+
payload hash, a non-zero schema version, and a unique 32-byte hexadecimal
7+
correlation ID. Reusing the correlation ID is rejected by the contract, so a
8+
network retry cannot create a second event.
9+
10+
```python
11+
client.record_structured_event(contract_id, "transfer", payload_hash, 1, correlation_id)
12+
```
13+
14+
```ts
15+
await client.recordStructuredEvent({ contractId, eventType: "transfer", payloadHash, schemaVersion: 1, correlationId });
16+
```
17+
318
Official SDKs for the SoroScan API - Stellar/Soroban event indexing.
419

520
## Strict type verification
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""SC-38 structured-event SDK coverage."""
2+
3+
import json
4+
5+
import pytest
6+
from pydantic import ValidationError
7+
from pytest_httpx import HTTPXMock
8+
9+
from soroscan import AsyncSoroScanClient, SoroScanClient
10+
from soroscan.models import StructuredEventRequest
11+
12+
13+
def test_structured_event_request_rejects_zero_schema_version() -> None:
14+
with pytest.raises(ValidationError):
15+
StructuredEventRequest(
16+
contract_id="CABC", event_type="transfer", payload_hash="a" * 64,
17+
schema_version=0, correlation_id="b" * 64,
18+
)
19+
20+
21+
def test_record_structured_event(base_url: str, httpx_mock: HTTPXMock) -> None:
22+
httpx_mock.add_response(
23+
url=f"{base_url}/api/record/structured/",
24+
status_code=202,
25+
json={"status": "submitted", "tx_hash": "abc", "transaction_status": "PENDING"},
26+
)
27+
with SoroScanClient(base_url=base_url) as client:
28+
result = client.record_structured_event("CABC", "transfer", "a" * 64, 1, "b" * 64)
29+
assert result.status == "submitted"
30+
request = httpx_mock.get_requests()[0]
31+
assert json.loads(request.content) == {
32+
"contract_id": "CABC", "event_type": "transfer", "payload_hash": "a" * 64,
33+
"schema_version": 1, "correlation_id": "b" * 64,
34+
}
35+
36+
37+
@pytest.mark.anyio
38+
async def test_async_record_structured_event(base_url: str, httpx_mock: HTTPXMock) -> None:
39+
httpx_mock.add_response(
40+
url=f"{base_url}/api/record/structured/", status_code=202,
41+
json={"status": "submitted", "tx_hash": "abc", "transaction_status": "PENDING"},
42+
)
43+
async with AsyncSoroScanClient(base_url=base_url) as client:
44+
result = await client.record_structured_event("CABC", "transfer", "a" * 64, 1, "b" * 64)
45+
assert result.tx_hash == "abc"

0 commit comments

Comments
 (0)