Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

All notable changes to blockrun-llm will be documented in this file.

## Unreleased

### Changed
- **Solana media polls prove job ownership instead of signing a payment.**
`SolanaLLMClient` / `AsyncSolanaLLMClient` poll `video()`, slow `image()` /
`image_edit()` and slow `music()` jobs with an ed25519 signature by the
submitting wallet (`x-poll-wallet` / `x-poll-timestamp` / `x-poll-signature`
over `blockrun-poll:v1:<kind>:<job id>:<unix seconds>`), signed fresh per
poll. A video signs exactly one payment, from the 402 its finished-but-unpaid
poll returns; images and music never pay on a poll. A refused proof falls back
once, per job, to the per-poll payment signature, with a warning on the
`blockrun_llm.solana_client` logger. Wallet rail on sol.blockrun.ai only; the
account rail and Base are unchanged.

### Fixed
- **Slow `music()` tracks on the Solana wallet rail are polled to completion.**
The paid POST's `202 + poll_url` stub was returned as the result and failed
`MusicResponse` validation after the track had been paid for.
- **Solana `image()`, `image_edit()`, `video()` and `video_from_content()`
enforce `max_cost_per_call` and `max_session_cost`.** They are checked before
the submit payment is signed, and again before a video's completion payment
is signed. Before this, these calls ignored both limits.
- **A video's completion payment that may have settled is never treated as
free.** If the paid poll is lost in transit, polling resumes and reads the
job's settlement record instead of abandoning the clip. A Retry-After on a
paid poll the gateway could not settle yet is honoured. An error or timeout
after the payment went out says it may still settle and is tagged as paid,
so no fallback chain buys a second job.
- **A completion challenge is not a delivered clip.** Under ownership polling,
only a 200 delivers. A 5xx on the paid poll is no longer returned as the
video and booked to the session. A clip that an earlier poll or call already
paid for is logged at zero cost.
- **A raw paid POST's challenge is kept per thread and per asyncio task.**
Concurrent music calls on one shared client can no longer poll, or fall back
to paying, against another call's challenge, and a cached answer no longer
inherits a previous call's challenge.

## 1.18.1 — 2026-10-02

### Fixed
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ blockrun_llm/
├── wallet.py # EVM wallet management
├── solana_wallet.py # Solana wallet management
├── x402.py # x402 payment protocol
├── poll_auth.py # Solana media-poll ownership proofs (x-poll-* headers)
├── router_core/ # Port of @blockrun/router-core (shared with the TS SDK + gateway)
├── router_adapter.py # Host glue: catalog ids, payment floors, free profile
├── router.py # Back-compat shim over router_core
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,25 @@ export SOLANA_WALLET_KEY="your-bs58-solana-key"
> what to switch to instead of failing with a cryptic "must be 66 characters"
> error.

**Polling slow media jobs.** `video()`, slow `image()` / `image_edit()` models
and slow `music()` tracks answer `202 + poll_url`, and the client polls until the
job finishes. On Solana a payment dies with its blockhash in ~60-90s, so instead
of signing a payment on every poll the client proves it owns the job: each poll
carries `x-poll-wallet`, `x-poll-timestamp` and `x-poll-signature`, an ed25519
signature by your wallet over `blockrun-poll:v1:<video|audio|image>:<job id>:<unix
seconds>`. No transaction, nothing a facilitator can settle.

- **Video** is charged once, when it is ready: the finished-but-unpaid poll
answers 402, the client signs one payment from that challenge and re-sends
the poll, which settles the clip. A poll budget that runs out costs nothing.
- **Images and music** were paid at submit, so their polls never sign a payment.
- If the gateway refuses the proof, the client falls back once, for that job,
to signing a payment per poll (logged as a warning on
`blockrun_llm.solana_client`).

The account rail (API key) and Base are unchanged: an API key is its own proof,
and a Base authorization lives for hours.

## Arc Support

The same `LLMClient` pays on [Circle's Arc](https://www.arc.network) via [arc.blockrun.ai](https://arc.blockrun.ai) — point `api_url` at it and hold USDC on Arc in the same EVM wallet:
Expand Down
124 changes: 124 additions & 0 deletions blockrun_llm/poll_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Proof of job ownership for Solana media polls, without a payment.

A Solana x402 payment is a transaction pinned to a recent blockhash that dies
in ~60-90s, so polling a multi-minute video by re-signing a payment meant a
fresh signature on almost every poll. sol.blockrun.ai also accepts a short
ed25519 signature over the job id from the wallet that submitted it:

message = "blockrun-poll:v1:<kind>:<job id>:<unix seconds>"
headers x-poll-wallet: base58 public key of the submitting wallet
x-poll-timestamp: the <unix seconds> in the message
x-poll-signature: base58 ed25519 signature over the UTF-8 message

``<kind>`` is ``video``, ``audio`` (music) or ``image``; ``<job id>`` is the
last path segment of the ``poll_url`` the POST returned, URL-decoded, which is
the value the gateway verifies against. The gateway accepts a timestamp within
300s of its clock, so the headers are signed fresh for every poll.

Server contract: blockrun-sol ``src/lib/poll-ownership.ts``. Base never sees
these headers: an EIP-3009 authorization lives for hours, so the Base clients
keep polling with their payment signature.
"""

from __future__ import annotations

import time
from collections.abc import Mapping
from typing import Any, Literal
from urllib.parse import unquote, urlsplit

PollKind = Literal["video", "audio", "image"]

POLL_WALLET_HEADER = "x-poll-wallet"
POLL_TIMESTAMP_HEADER = "x-poll-timestamp"
POLL_SIGNATURE_HEADER = "x-poll-signature"

# The gateway's poll routes, by the <kind> its signature must name. Matched on
# the path the POST returned, so an unknown route never gets a proof.
_KIND_BY_ROUTE: dict[str, PollKind] = {
"videos": "video",
"audio": "audio",
"images": "image",
}

# What each poll route answers a refused proof with: the same bytes as a job id
# that does not exist (wrong wallet, bad signature, stale timestamp, a job bound
# to no wallet).
_REFUSAL_BY_KIND: dict[PollKind, tuple[int, str]] = {
"video": (400, "Invalid job id"),
"audio": (404, "Job not found"),
"image": (404, "Job not found"),
}


def poll_kind_for_url(poll_url: str) -> PollKind | None:
"""The ``<kind>`` for a ``.../v1/<route>/generations/<id>`` poll URL, or
``None`` for any other path (no proof is sent there)."""
segments = [s for s in urlsplit(poll_url).path.split("/") if s]
if len(segments) < 4 or segments[-2] != "generations" or segments[-4] != "v1":
return None
return _KIND_BY_ROUTE.get(segments[-3])


def poll_job_id(poll_url: str) -> str:
"""The job id the gateway verifies: the poll URL's last path segment,
URL-decoded (the route reads ``decodeURIComponent(params.id)``)."""
path = urlsplit(poll_url).path.rstrip("/")
return unquote(path.rsplit("/", 1)[-1])


def poll_auth_message(kind: PollKind, job_id: str, timestamp_s: int) -> str:
"""The exact text the wallet signs."""
return f"blockrun-poll:v1:{kind}:{job_id}:{timestamp_s}"


def poll_auth_headers(
keypair: Any, kind: PollKind, job_id: str, *, now_s: int | None = None
) -> dict[str, str]:
"""Sign a fresh ownership proof for one poll.

``keypair`` is the wallet's ``solders.keypair.Keypair`` — the same key that
signed the submit payment, which is the payer the gateway bound the job to.
"""
timestamp_s = int(time.time()) if now_s is None else int(now_s)
message = poll_auth_message(kind, job_id, timestamp_s).encode("utf-8")
return {
POLL_WALLET_HEADER: str(keypair.pubkey()),
POLL_TIMESTAMP_HEADER: str(timestamp_s),
POLL_SIGNATURE_HEADER: str(keypair.sign_message(message)),
}


def is_poll_ownership_refusal(kind: PollKind, status_code: int, body: Any) -> bool:
"""True when a poll carrying x-poll-* got the route's "no such job" answer."""
expected_status, expected_error = _REFUSAL_BY_KIND[kind]
return (
status_code == expected_status
and isinstance(body, Mapping)
and body.get("error") == expected_error
)


def poll_keypair_of(signer: Any) -> Any:
"""The ``solders`` Keypair behind an x402 ``KeypairSigner``, or ``None``
when the signer does not expose one (then polls keep the payment path)."""
try:
from solders.keypair import Keypair
except ImportError: # pragma: no cover - solders ships with x402[svm]
return None
keypair = getattr(signer, "keypair", None)
return keypair if isinstance(keypair, Keypair) else None


__all__ = [
"POLL_SIGNATURE_HEADER",
"POLL_TIMESTAMP_HEADER",
"POLL_WALLET_HEADER",
"PollKind",
"is_poll_ownership_refusal",
"poll_auth_headers",
"poll_auth_message",
"poll_job_id",
"poll_keypair_of",
"poll_kind_for_url",
]
Loading
Loading