diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 3717048..eb69c6f 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -393,6 +393,13 @@ class SolanaLLMClient: # to refresh an expired blockhash, and every fresh signature is validated # against the original payment terms before use. MEDIA_POLL_MAX_RESIGNS = 2 + # Proactively re-sign the settlement authorization every N seconds during the + # poll loop so its recent-blockhash never ages out. The gateway settles only + # when upstream flips to "completed", and slow/flaky-status models (1080p + # Seedance) can bounce completed<->in_progress for minutes — long enough that + # a signature made earlier goes stale (blockhash lifetime ~60-90s) before the + # settling poll lands. 25s keeps every signature comfortably fresh. + MEDIA_RESIGN_FRESH_SECONDS = 25.0 # Media generation defaults (mirror the Base MusicClient/SpeechClient). MUSIC_DEFAULT_MODEL = "minimax/music-2.5+" @@ -1567,10 +1574,34 @@ def _request_image_with_payment( deadline = _time.monotonic() + budget last_status = submit_data.get("status", "queued") resigns_left = max_resigns + last_resign_at = _time.monotonic() while _time.monotonic() < deadline: _time.sleep(interval) + # Keep the settlement blockhash fresh (poll-based media path only, + # gated on max_resigns). Re-sign the ORIGINAL challenge — same amount/pay_to, + # only a freshly-fetched blockhash — so that whenever upstream flips to + # "completed" the signature is 0 + and _time.monotonic() - last_resign_at >= self.MEDIA_RESIGN_FRESH_SECONDS + ): + try: + fresh_payload = self._sign_payment(payment_required) + poll_headers["PAYMENT-SIGNATURE"] = encode_payment_signature_header( + fresh_payload + ) + last_resign_at = _time.monotonic() + except Exception: + # Best-effort only: a failed proactive re-sign (RPC hiccup, + # SolanaRpcException, etc.) must never abort the poll loop — + # we simply keep the prior signature (pre-fix behaviour). + pass + poll_resp = self._client.get(poll_url, headers=poll_headers, timeout=eff_timeout) try: poll_data = poll_resp.json() @@ -4094,10 +4125,30 @@ async def _request_image_with_payment( deadline = _time.monotonic() + budget last_status = submit_data.get("status", "queued") resigns_left = max_resigns + last_resign_at = _time.monotonic() while _time.monotonic() < deadline: await asyncio.sleep(interval) + # Keep the settlement blockhash fresh (poll-based media path only, + # gated on max_resigns) — mirror of the sync helper. Re-sign the + # ORIGINAL challenge (same amount/ + # pay_to, fresh blockhash) every MEDIA_RESIGN_FRESH_SECONDS so a slow / + # flaky-status model (1080p Seedance) can't age the signature out + # before the settling "completed" poll lands. Only completed settles. + if ( + max_resigns > 0 + and _time.monotonic() - last_resign_at >= SolanaLLMClient.MEDIA_RESIGN_FRESH_SECONDS + ): + try: + fresh_payload = await self._sign_payment(payment_required) + poll_headers["PAYMENT-SIGNATURE"] = encode_payment_signature_header( + fresh_payload + ) + last_resign_at = _time.monotonic() + except Exception: + pass + poll_resp = await self._client.get(poll_url, headers=poll_headers, timeout=eff_timeout) try: poll_data = poll_resp.json() diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index 48b97e6..6f16cfb 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -403,3 +403,154 @@ async def test_async_resign_reprice_propagates(self, monkeypatch: pytest.MonkeyP ) finally: await client._client.aclose() + + +# --------------------------------------------------------------------------- +# Proactive per-poll re-sign — keeps the settlement blockhash fresh even when +# NO poll ever 402s (the 1080p Seedance case: upstream status flaps +# completed<->in_progress for minutes and would otherwise settle a stale +# signature). Distinct from the on-402 re-sign guard tested above. +# --------------------------------------------------------------------------- + + +def _fresh_sig_handler(n_in_progress: int, poll_sigs: List[str]): + """Video job that NEVER 402s on a poll: n_in_progress in-progress polls, + then completed. Records the PAYMENT-SIGNATURE seen on every signed poll so a + test can assert the proactive re-sign refreshed it each time.""" + completed = { + "status": "completed", + "created": 1, + "model": "xai/grok-imagine-video", + "data": [{"url": "https://cdn/v.mp4"}], + } + state = {"i": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + if "PAYMENT-SIGNATURE" not in request.headers: + return httpx.Response( + 402, + headers={"content-type": "application/json", "payment-required": "stub"}, + json={"error": "Payment Required"}, + ) + return httpx.Response( + 202, + json={ + "id": "JOB", + "poll_url": "/api/v1/videos/generations/JOB", + "status": "queued", + }, + ) + poll_sigs.append(request.headers.get("PAYMENT-SIGNATURE")) + state["i"] += 1 + if state["i"] <= n_in_progress: + return httpx.Response( + 202, json={"status": "in_progress"}, headers={"content-type": "application/json"} + ) + return httpx.Response(200, json=completed, headers={"content-type": "application/json"}) + + return handler + + +class TestProactiveResign: + def test_sync_refreshes_signature_every_poll(self, monkeypatch: pytest.MonkeyPatch) -> None: + import itertools + + counter = itertools.count() + monkeypatch.setattr( + "blockrun_llm.solana_client.encode_payment_signature_header", + lambda payload: f"sig-{next(counter)}", + ) + # Fire the proactive re-sign on every poll (0s freshness window). + monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) + + poll_sigs: List[str] = [] + client = _make_client(_fresh_sig_handler(3, poll_sigs)) + data = client._request_image_with_payment( + "/v1/videos/generations", dict(_VIDEO_BODY), **_HELPER_KW + ) + assert data["data"][0]["url"] == "https://cdn/v.mp4" + # 3 in-progress + 1 completed, and every signed poll carried a DISTINCT + # (freshly re-signed) signature — the completed poll never reused the + # stale submit-time one. + assert len(poll_sigs) == 4 + assert len(set(poll_sigs)) == 4, poll_sigs + + @pytest.mark.asyncio + async def test_async_refreshes_signature_every_poll( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import itertools + + counter = itertools.count() + monkeypatch.setattr( + "blockrun_llm.solana_client.encode_payment_signature_header", + lambda payload: f"sig-{next(counter)}", + ) + monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) + + poll_sigs: List[str] = [] + client = _make_async_client(_fresh_sig_handler(3, poll_sigs)) + try: + data = await client._request_image_with_payment( + "/v1/videos/generations", dict(_VIDEO_BODY), **_HELPER_KW + ) + assert data["data"][0]["url"] == "https://cdn/v.mp4" + assert len(poll_sigs) == 4 + assert len(set(poll_sigs)) == 4, poll_sigs + finally: + await client._client.aclose() + + # max_resigns == 0 (the image path) must NOT proactively re-sign, even with a + # 0s freshness window: every poll reuses the single submit-time signature so + # the image flow is provably untouched by the video-only fix. + _IMAGE_KW: Dict[str, Any] = { + "poll_budget_seconds": 5.0, + "poll_interval_seconds": 0.001, + "max_resigns": 0, + "label": "Image generation", + } + + def test_sync_image_path_never_resigns(self, monkeypatch: pytest.MonkeyPatch) -> None: + import itertools + + counter = itertools.count() + monkeypatch.setattr( + "blockrun_llm.solana_client.encode_payment_signature_header", + lambda payload: f"sig-{next(counter)}", + ) + monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) + + poll_sigs: List[str] = [] + client = _make_client(_fresh_sig_handler(3, poll_sigs)) + data = client._request_image_with_payment( + "/v1/images/generations", dict(_VIDEO_BODY), **self._IMAGE_KW + ) + assert data["data"][0]["url"] == "https://cdn/v.mp4" + # 3 in-progress + 1 completed, every poll carrying the SAME submit-time + # signature — the proactive re-sign never fired for max_resigns == 0. + assert len(poll_sigs) == 4 + assert len(set(poll_sigs)) == 1, poll_sigs + + @pytest.mark.asyncio + async def test_async_image_path_never_resigns(self, monkeypatch: pytest.MonkeyPatch) -> None: + import itertools + + counter = itertools.count() + monkeypatch.setattr( + "blockrun_llm.solana_client.encode_payment_signature_header", + lambda payload: f"sig-{next(counter)}", + ) + monkeypatch.setattr(SolanaLLMClient, "MEDIA_RESIGN_FRESH_SECONDS", 0.0) + + poll_sigs: List[str] = [] + client = _make_async_client(_fresh_sig_handler(3, poll_sigs)) + try: + data = await client._request_image_with_payment( + "/v1/images/generations", dict(_VIDEO_BODY), **self._IMAGE_KW + ) + assert data["data"][0]["url"] == "https://cdn/v.mp4" + assert len(poll_sigs) == 4 + assert len(set(poll_sigs)) == 1, poll_sigs + finally: + await client._client.aclose()