Skip to content

Commit 0acf03d

Browse files
feat: implement SC-9 add_indexer across contract, API, and SDKs (#1178)
Wire the existing Soroban add_indexer function through the Django ingest API, Python/TypeScript SDK clients, and CLI. Closes #1114. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 67ff3e2 commit 0acf03d

19 files changed

Lines changed: 437 additions & 1 deletion

File tree

django-backend/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ SOROSCAN_CONTRACT_ID=CCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
7777

7878
# Optional indexer signing key.
7979
INDEXER_SECRET_KEY=
80+
ADMIN_SECRET_KEY=
8081

8182
TESTNET_RPC_URL=https://soroban-testnet.stellar.org
8283
MAINNET_RPC_URL=https://mainnet.stellar.validationcloud.io/v1/public

django-backend/soroscan/ingest/serializers.py

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

569569

570+
class AddIndexerRequestSerializer(serializers.Serializer):
571+
"""Serializer for SC-9: authorize an indexer on the SoroScan contract."""
572+
573+
indexer_address = serializers.CharField(
574+
max_length=56,
575+
help_text="Stellar address of the indexer to authorize",
570576
class StructuredEventRequestSerializer(RecordEventRequestSerializer):
571577
"""SC-38 request payload for versioned, idempotent contract events."""
572578

django-backend/soroscan/ingest/stellar_client.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ def _bytes_to_sc_val(self, data: bytes) -> SCVal:
144144
bytes=SCBytes(data),
145145
)
146146

147+
def _get_admin_keypair(self) -> Optional[Keypair]:
148+
admin_secret = getattr(settings, "ADMIN_SECRET_KEY", "") or self.secret_key
149+
if not admin_secret:
150+
return None
151+
return Keypair.from_secret(admin_secret)
152+
153+
def _submit_contract_transaction(
154+
self,
155+
function_name: str,
156+
parameters: list[SCVal],
157+
signer: Keypair,
158+
) -> TransactionResult:
159+
"""Simulate, prepare, and submit a Soroban contract write transaction."""
160+
try:
161+
account = self.server.load_account(signer.public_key)
162+
tx_builder = TransactionBuilder(
163+
source_account=account,
164+
network_passphrase=self.network_passphrase,
165+
base_fee=100000,
147166
def _simulate_contract_read(
148167
self,
149168
function_name: str,
@@ -169,6 +188,60 @@ def _simulate_contract_read(
169188
simulate_response = self.server.simulate_transaction(tx)
170189

171190
if simulate_response.error:
191+
return TransactionResult(
192+
success=False,
193+
tx_hash="",
194+
status="simulation_failed",
195+
error=simulate_response.error,
196+
)
197+
198+
prepared_tx = self.server.prepare_transaction(tx, simulate_response)
199+
prepared_tx.sign(signer)
200+
send_response = self.server.send_transaction(prepared_tx)
201+
202+
logger.info(
203+
"Contract transaction submitted: %s (%s)",
204+
send_response.hash,
205+
function_name,
206+
)
207+
208+
return TransactionResult(
209+
success=send_response.status == "PENDING",
210+
tx_hash=send_response.hash,
211+
status=send_response.status,
212+
result_xdr=getattr(send_response, "result_xdr", None),
213+
)
214+
except Exception as exc:
215+
logger.exception("Failed to submit contract transaction: %s", function_name)
216+
return TransactionResult(
217+
success=False,
218+
tx_hash="",
219+
status="error",
220+
error=str(exc),
221+
)
222+
223+
def add_indexer(self, indexer_address: str) -> TransactionResult:
224+
"""
225+
Submit an add_indexer transaction to the SoroScan contract (SC-9).
226+
227+
The configured admin keypair must sign the transaction.
228+
"""
229+
admin_keypair = self._get_admin_keypair()
230+
if not admin_keypair:
231+
return TransactionResult(
232+
success=False,
233+
tx_hash="",
234+
status="error",
235+
error="No admin keypair configured",
236+
)
237+
238+
return self._submit_contract_transaction(
239+
function_name="add_indexer",
240+
parameters=[
241+
self._address_to_sc_val(admin_keypair.public_key),
242+
self._address_to_sc_val(indexer_address),
243+
],
244+
signer=admin_keypair,
172245
return False, simulate_response.error
173246

174247
results = getattr(simulate_response, "results", None) or []

django-backend/soroscan/ingest/tests/test_stellar_client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,31 @@ def test_get_total_events(self, client):
177177
result = client.get_total_events()
178178

179179
assert result is None
180+
181+
def test_add_indexer_no_admin_keypair(self, mock_server, hex_contract_id):
182+
client = SorobanClient(
183+
rpc_url="https://soroban-testnet.stellar.org",
184+
contract_id=hex_contract_id,
185+
secret_key="",
186+
)
187+
result = client.add_indexer("G" + "A" * 54)
188+
assert result.success is False
189+
assert result.error == "No admin keypair configured"
190+
191+
def test_add_indexer_success(self, client, hex_contract_id):
192+
mock_account = MagicMock()
193+
mock_account.sequence = 1
194+
client.server = MagicMock()
195+
client.server.load_account.return_value = mock_account
196+
197+
mock_simulate_response = MagicMock()
198+
mock_simulate_response.error = None
199+
client.server.simulate_transaction.return_value = mock_simulate_response
200+
client.server.prepare_transaction.return_value = MagicMock()
201+
client.server.send_transaction.return_value = MagicMock(
202+
status="PENDING", hash="addindexer123"
203+
)
204+
205+
result = client.add_indexer("G" + "A" * 54)
206+
assert result.success is True
207+
assert result.tx_hash == "addindexer123"

django-backend/soroscan/ingest/tests/test_views.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,28 @@ def test_record_event_validation_error(self, authenticated_client):
403403
assert response.status_code == status.HTTP_400_BAD_REQUEST
404404
assert "contract_id" in response.data
405405

406+
@responses.activate
407+
def test_add_indexer_success(self, authenticated_client):
408+
responses.add(
409+
responses.POST,
410+
"https://soroban-testnet.stellar.org/",
411+
json={"status": "PENDING", "hash": "indexeradd123"},
412+
status=200,
413+
)
414+
415+
url = reverse("add-indexer")
416+
data = {"indexer_address": "G" + "A" * 54}
417+
response = authenticated_client.post(url, data, format="json")
418+
419+
assert response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_400_BAD_REQUEST]
420+
421+
def test_add_indexer_validation_error(self, authenticated_client):
422+
url = reverse("add-indexer")
423+
response = authenticated_client.post(url, {}, format="json")
424+
425+
assert response.status_code == status.HTTP_400_BAD_REQUEST
426+
assert "indexer_address" in response.data
427+
406428

407429
@pytest.mark.django_db
408430
class TestWebhookPingEndpoint:

django-backend/soroscan/ingest/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
health_check,
3030
networks_view,
3131
record_event_view,
32+
add_indexer_view,
3233
is_indexer_view,
3334
get_admin_view,
3435
record_structured_event_view,
@@ -93,6 +94,7 @@
9394
),
9495
path("", include(router.urls)),
9596
path("record/", record_event_view, name="record-event"),
97+
path("indexers/add/", add_indexer_view, name="add-indexer"),
9698
path("indexers/check/", is_indexer_view, name="is-indexer"),
9799
path("contract/admin/", get_admin_view, name="get-admin"),
98100
path("record/structured/", record_structured_event_view, name="record-structured-event"),

django-backend/soroscan/ingest/views.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
OrganizationCorsSerializer,
6868
OrganizationCostSnapshotSerializer,
6969
RecordEventRequestSerializer,
70+
AddIndexerRequestSerializer,
7071
StructuredEventRequestSerializer,
7172
TeamMemberAddSerializer,
7273
TeamSerializer,
@@ -1063,6 +1064,77 @@ def record_structured_event_view(request):
10631064

10641065

10651066
@extend_schema(
1067+
request=AddIndexerRequestSerializer,
1068+
responses={
1069+
202: inline_serializer(
1070+
name="AddIndexerResponse",
1071+
fields={
1072+
"status": serializers.CharField(),
1073+
"tx_hash": serializers.CharField(),
1074+
"transaction_status": serializers.CharField(),
1075+
},
1076+
),
1077+
400: inline_serializer(
1078+
name="AddIndexerFailed",
1079+
fields={
1080+
"status": serializers.CharField(),
1081+
"error": serializers.CharField(),
1082+
"transaction_status": serializers.CharField(),
1083+
},
1084+
),
1085+
},
1086+
)
1087+
@api_view(["POST"])
1088+
@permission_classes([IsAuthenticated])
1089+
@throttle_classes([IngestRateThrottle, AnonRateThrottle, UserRateThrottle])
1090+
def add_indexer_view(request):
1091+
"""
1092+
Authorize an indexer address on the SoroScan contract (SC-9).
1093+
1094+
Request body:
1095+
{
1096+
"indexer_address": "GABC..."
1097+
}
1098+
"""
1099+
serializer = AddIndexerRequestSerializer(data=request.data)
1100+
1101+
if not serializer.is_valid():
1102+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
1103+
1104+
indexer_address = serializer.validated_data["indexer_address"]
1105+
1106+
try:
1107+
client = SorobanClient()
1108+
result = client.add_indexer(indexer_address=indexer_address)
1109+
1110+
if result.success:
1111+
return Response(
1112+
{
1113+
"status": "submitted",
1114+
"tx_hash": result.tx_hash,
1115+
"transaction_status": result.status,
1116+
},
1117+
status=status.HTTP_202_ACCEPTED,
1118+
)
1119+
1120+
return Response(
1121+
{
1122+
"status": "failed",
1123+
"error": result.error,
1124+
"transaction_status": result.status,
1125+
},
1126+
status=status.HTTP_400_BAD_REQUEST,
1127+
)
1128+
1129+
except Exception as e:
1130+
logger.exception(
1131+
"Failed to add indexer",
1132+
extra={"indexer_address": indexer_address},
1133+
)
1134+
return Response(
1135+
{"status": "error", "error": str(e)},
1136+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
1137+
)
10661138
parameters=[
10671139
OpenApiParameter(
10681140
name="indexer_address",

django-backend/soroscan/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ def _load_software_version() -> str:
460460
)
461461
SOROSCAN_CONTRACT_ID = env("SOROSCAN_CONTRACT_ID", default="")
462462
INDEXER_SECRET_KEY = env("INDEXER_SECRET_KEY", default="")
463+
ADMIN_SECRET_KEY = env("ADMIN_SECRET_KEY", default=INDEXER_SECRET_KEY)
463464

464465
# Available Soroban networks exposed via GET /api/ingest/networks/.
465466
# Override individual RPC URLs via the corresponding env vars if needed.

sdk/python/soroscan/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
ContractStats,
2828
EventEntry,
2929
PaginatedResponse,
30+
AddIndexerRequest,
31+
AddIndexerResponse,
3032
IsIndexerResponse,
3133
GetAdminResponse,
3234
RecordEventsBatchRequest,
@@ -54,6 +56,8 @@
5456
"IsIndexerResponse",
5557
"GetAdminResponse",
5658
"EventEntry",
59+
"AddIndexerRequest",
60+
"AddIndexerResponse",
5761
"RecordEventsBatchRequest",
5862
"RecordEventsBatchResponse",
5963
"SoroScanError",

sdk/python/soroscan/cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,16 @@ def _handle_contracts(args: argparse.Namespace) -> int:
141141
return 0
142142

143143

144+
def _handle_indexers(args: argparse.Namespace) -> int:
145+
with _build_client(args) as client:
146+
result = client.add_indexer(args.indexer_address)
147+
if args.output == "json":
148+
_print_json(result)
149+
else:
150+
_print_table(
151+
[result],
152+
["status", "tx_hash", "transaction_status", "error"],
153+
)
144154
def _handle_record_event(args: argparse.Namespace) -> int:
145155
"""Submit a single event to the SoroScan contract (SC-10)."""
146156
with _build_client(args) as client:
@@ -261,6 +271,13 @@ def build_parser() -> argparse.ArgumentParser:
261271
record.add_argument("--output", choices=["table", "json"], default="table")
262272
record.set_defaults(func=_handle_record_event)
263273

274+
indexers = subcommands.add_parser("indexers", help="Manage Soroban contract indexers (SC-9)")
275+
indexer_subcommands = indexers.add_subparsers(dest="indexer_command", required=True)
276+
indexers_add = indexer_subcommands.add_parser("add", help="Authorize an indexer address")
277+
indexers_add.add_argument("indexer_address", help="Stellar address of the indexer")
278+
indexers_add.add_argument("--output", choices=["table", "json"], default="table")
279+
indexers_add.set_defaults(func=_handle_indexers)
280+
264281
return parser
265282

266283

0 commit comments

Comments
 (0)