Skip to content

Commit d04b4ee

Browse files
feat: contract pause/unpause circuit breaker (SC-28) (#1186)
Adds pause()/unpause()/is_paused() to the SoroScan core contract, letting the admin temporarily disable record_event()/record_events_batch() in an emergency. Exposed through both SDKs as get_contract_status()/ getContractStatus(). Also fixes test_event_decoding_and_types, which used tuple-field syntax (.topics/.value) incompatible with the soroban-sdk version this crate currently resolves to without a committed Cargo.lock — needed to get the contract test suite compiling at all. Closes #1133
1 parent 2a48ab6 commit d04b4ee

9 files changed

Lines changed: 408 additions & 19 deletions

File tree

sdk/python/soroscan/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ContractEvent,
2424
ContractHealth,
2525
ContractStats,
26+
ContractStatus,
2627
EventEntry,
2728
PaginatedResponse,
2829
AddIndexerRequest,
@@ -52,6 +53,7 @@
5253
"TrackedContract",
5354
"WebhookSubscription",
5455
"ContractStats",
56+
"ContractStatus",
5557
"GetEventsByContractsRequest",
5658
"GetEventsByContractsResponse",
5759
"PaginatedResponse",

sdk/python/soroscan/client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ContractEvent,
2020
ContractHealth,
2121
ContractStats,
22+
ContractStatus,
2223
EventEntry,
2324
PaginatedResponse,
2425
RecordEventRequest,
@@ -577,6 +578,18 @@ def record_events_batch(
577578
data = self._handle_response(response)
578579
return RecordEventsBatchResponse.model_validate(data)
579580

581+
def get_contract_status(self) -> ContractStatus:
582+
"""
583+
Get the contract's current pause/health status (SC-28).
584+
585+
Returns:
586+
ContractStatus with paused flag, admin address, and total event count
587+
"""
588+
url = urljoin(self.base_url, "/api/contract-status/")
589+
response = self._client.get(url, headers=self._get_headers())
590+
data = self._handle_response(response)
591+
return ContractStatus.model_validate(data)
592+
580593
def get_webhooks(
581594
self,
582595
page: int = 1,
@@ -1178,6 +1191,18 @@ async def record_events_batch(
11781191
data = self._handle_response(response)
11791192
return RecordEventsBatchResponse.model_validate(data)
11801193

1194+
async def get_contract_status(self) -> ContractStatus:
1195+
"""
1196+
Get the contract's current pause/health status (SC-28).
1197+
1198+
Returns:
1199+
ContractStatus with paused flag, admin address, and total event count
1200+
"""
1201+
url = urljoin(self.base_url, "/api/contract-status/")
1202+
response = await self._client.get(url, headers=self._get_headers())
1203+
data = self._handle_response(response)
1204+
return ContractStatus.model_validate(data)
1205+
11811206
async def get_webhooks(
11821207
self,
11831208
page: int = 1,

sdk/python/soroscan/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ class RecordEventsBatchResponse(BaseModel):
173173
error: str | None = Field(None, description="Error message if failed")
174174

175175

176+
# ── SC-28: Contract pause status ──────────────────────────────────────────────
177+
178+
class ContractStatus(BaseModel):
179+
"""Contract pause/health status (SC-28)."""
180+
181+
paused: bool = Field(..., description="Whether event recording is currently paused")
182+
admin: str = Field(..., description="Current admin address")
183+
total_events: int = Field(..., description="Total events recorded so far")
176184
# ── SC-16: Contract health ─────────────────────────────────────────────────────
177185

178186
class ContractHealth(BaseModel):
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for SC-28: contract pause status."""
2+
3+
import pytest
4+
from pytest_httpx import HTTPXMock
5+
6+
from soroscan import AsyncSoroScanClient, SoroScanClient
7+
from soroscan.models import ContractStatus
8+
9+
10+
# ── Fixtures ──────────────────────────────────────────────────────────────────
11+
12+
ACTIVE_STATUS_RESPONSE = {
13+
"paused": False,
14+
"admin": "GAAA111222333444555666777888999AAABBBCCCDDDEEEFFF",
15+
"total_events": 42,
16+
}
17+
18+
PAUSED_STATUS_RESPONSE = {
19+
"paused": True,
20+
"admin": "GAAA111222333444555666777888999AAABBBCCCDDDEEEFFF",
21+
"total_events": 42,
22+
}
23+
24+
25+
# ── Sync client ───────────────────────────────────────────────────────────────
26+
27+
28+
def test_get_contract_status_active(base_url: str, httpx_mock: HTTPXMock) -> None:
29+
"""get_contract_status returns a not-paused status."""
30+
httpx_mock.add_response(
31+
url=f"{base_url}/api/contract-status/",
32+
json=ACTIVE_STATUS_RESPONSE,
33+
status_code=200,
34+
)
35+
36+
with SoroScanClient(base_url=base_url) as client:
37+
result = client.get_contract_status()
38+
39+
assert isinstance(result, ContractStatus)
40+
assert result.paused is False
41+
assert result.admin == "GAAA111222333444555666777888999AAABBBCCCDDDEEEFFF"
42+
assert result.total_events == 42
43+
44+
45+
def test_get_contract_status_paused(base_url: str, httpx_mock: HTTPXMock) -> None:
46+
"""get_contract_status returns a paused status."""
47+
httpx_mock.add_response(
48+
url=f"{base_url}/api/contract-status/",
49+
json=PAUSED_STATUS_RESPONSE,
50+
status_code=200,
51+
)
52+
53+
with SoroScanClient(base_url=base_url) as client:
54+
result = client.get_contract_status()
55+
56+
assert isinstance(result, ContractStatus)
57+
assert result.paused is True
58+
assert result.total_events == 42
59+
60+
61+
def test_contract_status_payload_validation_missing_admin() -> None:
62+
"""ContractStatus rejects a payload missing the required admin field."""
63+
from pydantic import ValidationError
64+
65+
with pytest.raises(ValidationError):
66+
ContractStatus(paused=False, total_events=1) # type: ignore[call-arg]
67+
68+
69+
# ── Async client ──────────────────────────────────────────────────────────────
70+
71+
72+
@pytest.mark.anyio
73+
async def test_async_get_contract_status(base_url: str, httpx_mock: HTTPXMock) -> None:
74+
"""Async get_contract_status fetches correctly and returns response."""
75+
httpx_mock.add_response(
76+
url=f"{base_url}/api/contract-status/",
77+
json=ACTIVE_STATUS_RESPONSE,
78+
status_code=200,
79+
)
80+
81+
async with AsyncSoroScanClient(base_url=base_url) as client:
82+
result = await client.get_contract_status()
83+
84+
assert isinstance(result, ContractStatus)
85+
assert result.paused is False
86+
assert result.total_events == 42
87+
88+
89+
@pytest.mark.anyio
90+
async def test_async_get_contract_status_paused(base_url: str, httpx_mock: HTTPXMock) -> None:
91+
"""Async get_contract_status returns a paused status."""
92+
httpx_mock.add_response(
93+
url=f"{base_url}/api/contract-status/",
94+
json=PAUSED_STATUS_RESPONSE,
95+
status_code=200,
96+
)
97+
98+
async with AsyncSoroScanClient(base_url=base_url) as client:
99+
result = await client.get_contract_status()
100+
101+
assert result.paused is True

sdk/typescript/src/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
PaginatedResponse,
2626
RecordEventsBatchParams,
2727
RecordEventsBatchResponse,
28+
ContractStatus,
2829
AddIndexerParams,
2930
AddIndexerResponse,
3031
GetAdminResponse,
@@ -477,6 +478,17 @@ export class SoroScanClient {
477478
);
478479
}
479480

481+
/**
482+
* Get the contract's current pause/health status (SC-28).
483+
*
484+
* @example
485+
* const status = await client.getContractStatus();
486+
* console.log('Paused:', status.paused);
487+
*/
488+
async getContractStatus(): Promise<ContractStatus> {
489+
return this.#request<ContractStatus>("GET", "/v1/contract-status");
490+
}
491+
480492
/**
481493
* Create a new webhook subscription.
482494
*

sdk/typescript/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export type {
6262
EventEntry,
6363
RecordEventsBatchParams,
6464
RecordEventsBatchResponse,
65+
// SC-28: Contract pause status
66+
ContractStatus,
6567
// SC-30: Recent contract events
6668
GetContractRecentEventsParams,
6769
// WebSocket

sdk/typescript/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,19 @@ export interface EventFilter {
510510
topics?: Partial<ContractEventTopic>[];
511511
}
512512

513+
// ─────────────────────────────────────────────────────────────────────────────
514+
// SC-28: Contract pause status
515+
// ─────────────────────────────────────────────────────────────────────────────
516+
517+
export interface ContractStatus {
518+
/** Whether event recording is currently paused */
519+
paused: boolean;
520+
/** Current admin address */
521+
admin: string;
522+
/** Total events recorded so far */
523+
totalEvents: number;
524+
}
525+
513526
// ─────────────────────────────────────────────────────────────────────────────
514527
// Errors
515528
// ─────────────────────────────────────────────────────────────────────────────
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { SoroScanClient, SoroScanError } from "../src/client.js";
3+
import type { ContractStatus } from "../src/types.js";
4+
5+
// ─────────────────────────────────────────────────────────────────────────────
6+
// Helpers
7+
// ─────────────────────────────────────────────────────────────────────────────
8+
9+
function mockFetch(body: unknown, status = 200): void {
10+
vi.stubGlobal(
11+
"fetch",
12+
vi.fn().mockResolvedValue(
13+
new Response(JSON.stringify(body), {
14+
status,
15+
headers: { "Content-Type": "application/json" },
16+
})
17+
)
18+
);
19+
}
20+
21+
const BASE_URL = "https://api.soroscan.io";
22+
const makeClient = () => new SoroScanClient({ baseUrl: BASE_URL, apiKey: "test-key" });
23+
24+
const MOCK_ACTIVE_STATUS: ContractStatus = {
25+
paused: false,
26+
admin: "GAAA111222333444555666777888999AAABBBCCCDDDEEEFFF",
27+
totalEvents: 42,
28+
};
29+
30+
const MOCK_PAUSED_STATUS: ContractStatus = {
31+
paused: true,
32+
admin: "GAAA111222333444555666777888999AAABBBCCCDDDEEEFFF",
33+
totalEvents: 42,
34+
};
35+
36+
// ─────────────────────────────────────────────────────────────────────────────
37+
// SC-28: getContractStatus
38+
// ─────────────────────────────────────────────────────────────────────────────
39+
40+
describe("getContractStatus() — SC-28", () => {
41+
afterEach(() => vi.restoreAllMocks());
42+
43+
it("returns a correctly-parsed status when not paused", async () => {
44+
mockFetch(MOCK_ACTIVE_STATUS, 200);
45+
const result = await makeClient().getContractStatus();
46+
47+
expect(result.paused).toBe(false);
48+
expect(result.admin).toBe(MOCK_ACTIVE_STATUS.admin);
49+
expect(result.totalEvents).toBe(42);
50+
});
51+
52+
it("returns a correctly-parsed status when paused", async () => {
53+
mockFetch(MOCK_PAUSED_STATUS, 200);
54+
const result = await makeClient().getContractStatus();
55+
56+
expect(result.paused).toBe(true);
57+
expect(result.totalEvents).toBe(42);
58+
});
59+
60+
it("calls the correct URL with GET method", async () => {
61+
mockFetch(MOCK_ACTIVE_STATUS, 200);
62+
await makeClient().getContractStatus();
63+
64+
const [url, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [
65+
string,
66+
RequestInit,
67+
];
68+
expect(url).toContain("/v1/contract-status");
69+
expect(init.method).toBe("GET");
70+
});
71+
72+
it("includes Authorization header", async () => {
73+
mockFetch(MOCK_ACTIVE_STATUS, 200);
74+
await makeClient().getContractStatus();
75+
76+
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [
77+
string,
78+
RequestInit,
79+
];
80+
expect((init.headers as Record<string, string>)["Authorization"]).toBe(
81+
"Bearer test-key"
82+
);
83+
});
84+
85+
it("throws SoroScanError on 401 unauthorized", async () => {
86+
mockFetch({ code: "UNAUTHORIZED", message: "Invalid API key" }, 401);
87+
await expect(makeClient().getContractStatus()).rejects.toMatchObject({
88+
name: "SoroScanError",
89+
statusCode: 401,
90+
code: "UNAUTHORIZED",
91+
});
92+
});
93+
94+
it("throws SoroScanError on 500 server error", async () => {
95+
mockFetch({ code: "SERVER_ERROR", message: "Internal error" }, 500);
96+
await expect(makeClient().getContractStatus()).rejects.toBeInstanceOf(
97+
SoroScanError
98+
);
99+
});
100+
});

0 commit comments

Comments
 (0)