1- """client.py — LLMClient: automatic x402 V2 handshake (§8.4, fix.md 4.2 steps 1–2) .
1+ """client.py — LLMClient: automatic x402 V2 handshake.
22
33Flow: POST /v1/chat/completions → 402 (PAYMENT-REQUIRED) → partial TX
4- (TransferChecked + Memo, §3.3–§3.4 ) via the official x402 SVM scheme →
4+ (TransferChecked + Memo) via the official x402 SVM scheme →
55PAYMENT-SIGNATURE → retry → 200.
66
7- step 2 — Receipt verification + spending policy (fail-closed, like ASG ):
7+ step 2 — Receipt verification + spending policy (fail-closed):
88- After 200, the `PAYMENT-RESPONSE` receipt is verified: success=true,
99 network = Solana mainnet, payer = our wallet, transaction = fee payer
1010 signature over OUR TX message (Free-Riding protection: a forged receipt
1515
1616Rules:
1717- Key from `.env` (`BRIDGENODE_WALLET_KEY`) — no arguments, no interactive
18- prompts (§8.4)
18+ prompts
1919- Endpoint: `https://bridgenode.cc/v1` (configurable via
2020 `BRIDGENODE_BASE_URL` or argument)
21- - Two separate timeouts (§4.3/§8.4) : initial ≥ 30s (queue until 402, §5.7 ),
21+ - Two separate timeouts: initial ≥ 30s (queue until 402),
2222 retry ≥ 113s (≤ 115s budget)
2323- Uses the official x402 client (x402ClientSync + ExactSvmScheme) — no custom
24- payment code (taisykles.md: don't reinvent the wheel)
24+ payment code (don't reinvent the wheel)
2525"""
2626
2727from __future__ import annotations
5959USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" # Solana mainnet USDC mint
6060USDC_DECIMALS = 6
6161
62- # §5.7/§8.4: the initial request waits in the queue until 402 (30s queue + 30s
63- # window); retry with PAYMENT-SIGNATURE — up to the 115s budget (settle 20 + provider 30×3)
62+ # The initial request waits in the queue until 402 (30s queue + 30s
63+ # window); retry with PAYMENT-SIGNATURE — up to the 115s retry budget
6464INITIAL_TIMEOUT_S = 60.0
6565RETRY_TIMEOUT_S = 115.0
66- # §8.4: total flow timeout ≥ sum of both (initial + retry ≈ 175s)
66+ # Total flow timeout ≥ sum of both (initial + retry ≈ 175s)
6767FLOW_TIMEOUT_S = INITIAL_TIMEOUT_S + RETRY_TIMEOUT_S
6868
69- # Spending policy (fix.md 4.2 step 2) : fail-closed, like ASG
69+ # Spending policy: fail-closed
7070DEFAULT_MAX_PER_CALL_USD = 0.05
7171DEFAULT_DAILY_CAP_USD = 1.0
7272
7373
7474class BridgenodeError (Exception ):
75- """BridgeNode SDK error — server error body (OpenAI format, §5.6 )."""
75+ """BridgeNode SDK error — server error body (OpenAI format)."""
7676
7777 def __init__ (self , message : str , status_code : int | None = None ,
7878 code : str | None = None ) -> None :
@@ -83,7 +83,7 @@ def __init__(self, message: str, status_code: int | None = None,
8383
8484
8585def _error_message (resp : httpx .Response ) -> str :
86- """Server error message from the OpenAI-format body (§5.6) ."""
86+ """Server error message from the OpenAI-format body."""
8787 try :
8888 data = resp .json ()
8989 err = data .get ("error" , {})
@@ -94,7 +94,7 @@ def _error_message(resp: httpx.Response) -> str:
9494 return f"HTTP { resp .status_code } "
9595
9696
97- # fix.md §3: server errorReason → agent-readable hint (if known)
97+ # server errorReason → agent-readable hint (if known)
9898_ERROR_REASON_HINTS = {
9999 "insufficient_funds" : " — fund your wallet with USDC" ,
100100}
@@ -125,7 +125,7 @@ def __init__(
125125 env_path : str = ".env" ,
126126 transport : httpx .BaseTransport | None = None ,
127127 ) -> None :
128- """Creates the client. Key ONLY from `.env` (§8.4 — no arguments)."""
128+ """Creates the client. Key ONLY from `.env` (no arguments)."""
129129 if load_dotenv is not None :
130130 load_dotenv (env_path )
131131
@@ -148,7 +148,7 @@ def __init__(
148148 os .environ .get ("BRIDGENODE_DAILY_CAP" , DEFAULT_DAILY_CAP_USD ))
149149 self ._daily_spend : dict [str , float ] = {}
150150
151- # Official x402 client + SVM signer (§8.2, P4)
151+ # Official x402 client + SVM signer
152152 signer = KeypairSigner .from_base58 (self .wallet_key )
153153 self ._signer = signer
154154 self .wallet_address = signer .address
@@ -159,7 +159,7 @@ def __init__(
159159 )
160160 # Official practice (docs.x402.org lifecycle-hooks): spending policy
161161 # enforced IN CODE, next to the payment client — protection remains even
162- # if env vars are missing; the hook runs BEFORE payload creation (§8.4)
162+ # if env vars are missing; the hook runs BEFORE payload creation
163163 self ._x402 .on_before_payment_creation (self ._spending_policy_hook )
164164 self ._http_helper = x402HTTPClientSync (self ._x402 )
165165 self ._http = httpx .Client (transport = transport )
@@ -170,7 +170,7 @@ def __init__(
170170 def _post (self , url : str , * , json : dict | None = None ,
171171 headers : dict | None = None ,
172172 timeout : float | None = None ) -> httpx .Response :
173- """POST with network errors → BridgenodeError (fix.md 11) .
173+ """POST with network errors → BridgenodeError.
174174
175175 httpx.ConnectError / TimeoutException would otherwise leak as raw
176176 httpx exceptions — the agent expects BridgenodeError everywhere.
@@ -184,7 +184,7 @@ def _post(self, url: str, *, json: dict | None = None,
184184 raise BridgenodeError (f"Request timed out: { exc } " ) from exc
185185
186186 def _send_stream (self , req : httpx .Request ) -> httpx .Response :
187- """Streaming send with network errors → BridgenodeError (fix.md 11) ."""
187+ """Streaming send with network errors → BridgenodeError."""
188188 try :
189189 return self ._http .send (req , stream = True )
190190 except httpx .ConnectError as exc :
@@ -197,26 +197,26 @@ def _send_stream(self, req: httpx.Request) -> httpx.Response:
197197 def chat (self , model : str | None , messages : str | list [dict ],
198198 max_tokens : int | None = None , mode : str | None = None ,
199199 stream : bool = False ) -> dict [str , Any ] | Any :
200- """Single chat completion via the automatic x402 handshake (§4.1) .
200+ """Single chat completion via the automatic x402 handshake.
201201
202- item 41 (§8.4 example): ``messages`` can be a string (automatically
202+ ``messages`` can be a string (automatically
203203 converted to ``[{"role": "user", "content": ...}]``) or the
204204 OpenAI format (list[dict]) — the server still receives an OpenAI body.
205205
206- ``stream=True`` (optional, §5.5 ): returns an iterator of OpenAI SSE
206+ ``stream=True`` (optional): returns an iterator of OpenAI SSE
207207 chunks (``dict`` with ``choices[].delta``), terminated by the
208208 ``[DONE]`` marker; the receipt is verified and spend recorded BEFORE
209- the first chunk is yielded (billing boundary, §5.5 ). Default (False)
209+ the first chunk is yielded (billing boundary). Default (False)
210210 returns the full JSON response — backward-compatible.
211211
212212 step 2: spending policy BEFORE signing; PAYMENT-RESPONSE receipt
213213 verification after 200. Errors → BridgenodeError.
214214 """
215- # item 41: string prompt → OpenAI messages format (client side, §8.4 )
215+ # string prompt → OpenAI messages format (client side)
216216 if isinstance (messages , str ):
217217 messages = [{"role" : "user" , "content" : messages }]
218218 url = f"{ self .base_url } /chat/completions"
219- # item 25: `model` omitted when None — body without `model: null`
219+ # `model` omitted when None — body without `model: null`
220220 # (when sending only `mode`, the server would get JSON null → possible 400)
221221 body : dict [str , Any ] = {"messages" : messages }
222222 if model is not None :
@@ -230,7 +230,7 @@ def chat(self, model: str | None, messages: str | list[dict],
230230 headers = {"Content-Type" : "application/json" }
231231 payload = None # PaymentPayload — for step 2 receipt verification
232232
233- # item 42 (§8.4): total flow timeout — the whole handshake (initial + SIWX
233+ # total flow timeout — the whole handshake (initial + SIWX
234234 # + payment retry) must fit within the budget; exceeded → BridgenodeError
235235 deadline = time .monotonic () + self .flow_timeout
236236
@@ -239,11 +239,11 @@ def _flow_timeout(call_timeout: float) -> float:
239239 remaining = deadline - time .monotonic ()
240240 if remaining <= 0 :
241241 raise BridgenodeError (
242- f"Flow timeout exceeded ({ self .flow_timeout :.0f} s, §8.4 )" )
242+ f"Flow timeout exceeded ({ self .flow_timeout :.0f} s)" )
243243 return min (call_timeout , remaining )
244244
245- # 1) Initial request (no payment): queue until 402 (§5.7)
246- # Client-side retry (§5.7 "Agentas retry'ina"): 503 ( queue full /
245+ # 1) Initial request (no payment): queue until 402
246+ # Client-side retry (503 queue full /
247247 # wait timeout) and 429 (per-agent queue cap / 402 rate limit) are
248248 # retried with backoff — BEFORE any payment (nothing was charged,
249249 # retry is free). Retry-After header is honoured when present.
@@ -268,13 +268,13 @@ def _flow_timeout(call_timeout: float) -> float:
268268 else backoff_s * (2 ** attempt ), 15.0 )
269269 time .sleep (wait )
270270
271- # 2) 402 → SIWX (step 3) first, then spending policy + payment (§5.7)
271+ # 2) 402 → SIWX first, then spending policy + payment
272272 if resp .status_code == 402 :
273273 get_header , _body_data = self ._resp_headers (resp )
274274 payment_required = self ._http_helper .get_payment_required_response (
275275 get_header , resp .content )
276276 # SIWX: 402 with challenge → sign → retry with SIGN-IN-WITH-X
277- # (official create_siwx_client_hook); auth fails → payment (§5.7)
277+ # (official create_siwx_client_hook); auth fails → payment
278278 siwx_header = self ._build_siwx_header (payment_required , str (resp .url ))
279279 if siwx_header :
280280 resp = self ._post (
@@ -286,13 +286,13 @@ def _flow_timeout(call_timeout: float) -> float:
286286 get_header , _body_data = self ._resp_headers (resp )
287287 payment_required = self ._http_helper .get_payment_required_response (
288288 get_header , resp .content )
289- # item 23: fail-closed — pick a supported accepts entry
290- # (exact + Solana mainnet + USDC, §3.1 ); the SDK does not check
289+ # fail-closed — pick a supported accepts entry
290+ # (exact + Solana mainnet + USDC); the SDK does not check
291291 # asset — verified here, BEFORE signing (no TX for other mint/network)
292292 selected = self ._select_payment_requirement (payment_required )
293- # B-4: malformed server amount (decimal/garbage/negative) must
293+ # malformed server amount (decimal/garbage/negative) must
294294 # surface as BridgenodeError, not a raw ValueError crash
295- # (§8.4 SDK fail-closed).
295+ # (SDK fail-closed).
296296 try :
297297 amount_atomic = int (selected .amount )
298298 except (TypeError , ValueError ):
@@ -308,12 +308,12 @@ def _flow_timeout(call_timeout: float) -> float:
308308
309309 pay_headers , payload = self ._http_helper .handle_402_response (
310310 dict (resp .headers ), resp .content , str (resp .url ))
311- # item 22: payment retry WITHOUT SIGN-IN-WITH-X — official pattern
311+ # payment retry WITHOUT SIGN-IN-WITH-X — official pattern
312312 # "SIWX or payment" (nonce is single-use, already consumed in
313- # the SIWX retry; §5.7 ) — hook_headers only for the SIWX retry
313+ # the SIWX retry) — hook_headers only for the SIWX retry
314314 retry_headers = {** headers , ** pay_headers }
315315 if stream :
316- # SSE (§5.5) : stream the retry — headers are available
316+ # SSE: stream the retry — headers are available
317317 # immediately, the body is read chunk-by-chunk below
318318 req = self ._http .build_request (
319319 "POST" , url , json = body , headers = retry_headers ,
@@ -325,7 +325,7 @@ def _flow_timeout(call_timeout: float) -> float:
325325 timeout = _flow_timeout (self .retry_timeout ))
326326
327327 if resp .status_code != 200 :
328- # fix.md §3: 402 with PAYMENT-RESPONSE — relay the server errorReason
328+ # 402 with PAYMENT-RESPONSE — relay the server errorReason
329329 # (e.g., insufficient_funds) so the agent understands and acts
330330 if stream :
331331 resp .read () # streamed body — materialize before parsing
@@ -341,10 +341,10 @@ def _flow_timeout(call_timeout: float) -> float:
341341 pass # no PAYMENT-RESPONSE — initial 402 (no payment)
342342 raise BridgenodeError (message , status_code = resp .status_code )
343343
344- # P3#19 (fix.md item 16): spend recorded ONLY after a successful 200 — retry
344+ # Spend recorded ONLY after a successful 200 — retry
345345 # failure (5xx) → the server refunds, a pessimistic cap is unnecessary
346346 # (step 2: receipt verification BEFORE recording spend — if the receipt
347- # is forged, the spend is NOT recorded, daily cap stays intact; R16/Ž16 )
347+ # is forged, the spend is NOT recorded, daily cap stays intact)
348348 if payload is not None :
349349 self ._verify_receipt (payload , resp )
350350 self ._record_spend (amount_usd )
@@ -353,7 +353,7 @@ def _flow_timeout(call_timeout: float) -> float:
353353 return resp .json ()
354354
355355 def _iter_sse (self , resp : httpx .Response ) -> Any :
356- """Yield OpenAI SSE chunks from a streamed response (§5.5) .
356+ """Yield OpenAI SSE chunks from a streamed response.
357357
358358 Each ``data:`` line is parsed as JSON and yielded as a dict; the
359359 stream ends at ``data: [DONE]``. The response is closed when the
@@ -374,7 +374,7 @@ def _iter_sse(self, resp: httpx.Response) -> Any:
374374 resp .close ()
375375
376376 def list_models (self ) -> list [dict [str , Any ]]:
377- """List available models + prices from GET /v1/models (§5.2) .
377+ """List available models + prices from GET /v1/models.
378378
379379 Public endpoint — no payment, no authentication. Returns the
380380 ``data`` array (model id, pricing.prompt/completion,
@@ -391,18 +391,18 @@ def list_models(self) -> list[dict[str, Any]]:
391391 data = resp .json ()
392392 return data .get ("data" , [])
393393
394- # ── SIWX (step 3, §5.7) ────────────────────────────────────────────────────
394+ # ── SIWX ────────────────────────────────────────────────────
395395
396396 def _build_siwx_header (self , payment_required , request_url : str ) -> str | None :
397- """SIGN-IN-WITH-X header from the 402 SIWX challenge (official hook, §5.7 ).
397+ """SIGN-IN-WITH-X header from the 402 SIWX challenge (official hook).
398398
399- Uses the official ``create_siwx_client_hook`` (P4) — our signer is a
399+ Uses the official ``create_siwx_client_hook`` — our signer is a
400400 solders Keypair, so the signature is sync; the hook is async →
401401 ``asyncio.run``. Returns None if the 402 has no SIWX extension or the
402402 chain is unsupported.
403403
404- P3#18 (fix.md item 16): call from a RUNNING event loop → SIWX skipped
405- (fallback to payment, §5.7 ) — ``asyncio.run`` would raise RuntimeError.
404+ Call from a RUNNING event loop → SIWX skipped
405+ (fallback to payment) — ``asyncio.run`` would raise RuntimeError.
406406 Documented: the sync SDK targets non-async contexts.
407407 """
408408 import asyncio
@@ -415,22 +415,22 @@ def _build_siwx_header(self, payment_required, request_url: str) -> str | None:
415415 else :
416416 logger .warning (
417417 "SIWX skipped — called from a running event loop "
418- "(fallback to payment, §5.7; P3#18 )" )
418+ "(fallback to payment)" )
419419 return None
420420
421421 try :
422422 hook = create_siwx_client_hook (self ._signer )
423- # x402 2.20.0 (FAZĖ 3 #7.5): the hook context requires request_url
423+ # the hook context requires request_url
424424 result = asyncio .run (hook (
425425 SimpleNamespace (payment_required = payment_required ,
426426 request_url = request_url )))
427427 except Exception :
428- return None # no SIWX — fallback to payment (§5.7)
428+ return None # no SIWX — fallback to payment
429429 if result is None :
430430 return None
431431 return result .headers .get (SIGN_IN_WITH_X )
432432
433- # ── Supported entry selection (item 23, §3.1) ──────────────────────────────
433+ # ── Supported entry selection ──────────────────────────────
434434
435435 def _select_payment_requirement (self , payment_required ):
436436 """Fail-closed: supported accepts entry (exact + Solana mainnet + USDC).
@@ -439,7 +439,7 @@ def _select_payment_requirement(self, payment_required):
439439 SVM, Solana mainnet) — it does not check the asset. So we verify here
440440 BEFORE signing: the first SDK-supported entry MUST be USDC; otherwise
441441 (different mint, different network, or empty accepts) →
442- BridgenodeError — no TX (§3.1 " agent SDKs automatically select a
442+ BridgenodeError — no TX (agent SDKs automatically select a
443443 supported entry").
444444 """
445445 for req in payment_required .accepts :
@@ -459,7 +459,7 @@ def _select_payment_requirement(self, payment_required):
459459 # ── Spending policy (step 2, fail-closed) ──────────────────────────────────
460460
461461 def _spending_policy_hook (self , context ) -> AbortResult | None :
462- """Spending policy as a lifecycle hook (official practice, §8.4 ).
462+ """Spending policy as a lifecycle hook (official practice).
463463
464464 Registered as ``on_before_payment_creation`` — runs BEFORE payment
465465 payload creation, next to the payment client. Returns AbortResult if
@@ -507,7 +507,7 @@ def get_header(name: str) -> str | None:
507507 return get_header , None
508508
509509 def _verify_receipt (self , payload : Any , resp : httpx .Response ) -> None :
510- """Verifies the PAYMENT-RESPONSE receipt (§7, Free-Riding protection).
510+ """Verifies the PAYMENT-RESPONSE receipt (Free-Riding protection).
511511
512512 Required: success=true, network = Solana mainnet, payer = our wallet,
513513 transaction = fee payer signature over OUR TX message (forged/incorrect
0 commit comments