diff --git a/src/solana-client.ts b/src/solana-client.ts index 00b7e52..fa9ec7a 100644 --- a/src/solana-client.ts +++ b/src/solana-client.ts @@ -57,6 +57,106 @@ import { USER_AGENT } from "./version"; const SOLANA_API_URL = "https://sol.blockrun.ai/api"; const DEFAULT_MAX_TOKENS = 1024; const DEFAULT_TIMEOUT = 60000; +const STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2000] as const; +const MAX_PAYMENT_FAILURE_BYTES = 64 * 1024; + +class SafeStaleBlockhashError extends PaymentError { + constructor() { + super("Payment verification used an expired Solana blockhash; retrying with a fresh quote."); + this.name = "SafeStaleBlockhashError"; + } +} + +function normalizePaymentSignal(value: unknown): string { + return typeof value === "string" + ? value.toLowerCase().replace(/[_\-\s:]/g, "") + : ""; +} + +async function readPaymentFailureBody(response: Response): Promise { + // The paid 402 is terminal or converted into an internal retry signal; it is + // never returned to callers, so consume the original body. Cloning would tee + // the stream, and cancelling one oversized tee branch can wait forever for + // the unread sibling branch. + const reader = response.body?.getReader(); + if (!reader) return ""; + const decoder = new TextDecoder(); + let total = 0; + let text = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return text + decoder.decode(); + total += value.byteLength; + if (total > MAX_PAYMENT_FAILURE_BYTES) { + void reader.cancel(); + return null; + } + text += decoder.decode(value, { stream: true }); + } + } catch { + return null; + } +} + +/** + * Recognize only explicitly verification-phase stale-blockhash failures. + * Settlement may already have broadcast a transaction, so any settlement or + * phase-ambiguous 402 is terminal even when it mentions blockhash expiry. + * @internal Exported for the payment safety regression tests. + */ +export async function isSafeStaleBlockhashResponse(response: Response): Promise { + const length = Number(response.headers.get("content-length") || "0"); + if (Number.isFinite(length) && length > MAX_PAYMENT_FAILURE_BYTES) return false; + + const text = await readPaymentFailureBody(response); + if (text === null) return false; + + let body: Record; + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + body = parsed as Record; + } catch { + return false; + } + + const nested = body.error && typeof body.error === "object" && !Array.isArray(body.error) + ? body.error as Record + : undefined; + const errorLabel = typeof body.error === "string" ? body.error : ""; + const code = normalizePaymentSignal(body.code ?? nested?.code); + const reason = normalizePaymentSignal(body.reason); + const detail = normalizePaymentSignal(body.invalidMessage); + const message = normalizePaymentSignal(nested?.message ?? body.message); + const label = normalizePaymentSignal(errorLabel); + + if ( + code.includes("settlementfailed") || + label.includes("settlementfailed") || + message.includes("settlementfailed") + ) return false; + + const verifyPhase = + code === "paymentinvalid" || + label.includes("verificationfailed") || + message.includes("verificationfailed"); + if (!verifyPhase) return false; + + return ( + code === "paymentblockhashstale" || + detail.includes("blockhashnotfound") || + detail.includes("blockheightexceeded") || + reason === "expiredsignature" || + message.includes("expiredsignature") + ); +} + +async function waitForStaleRetry(attempt: number): Promise { + await new Promise((resolve) => + setTimeout(resolve, STALE_BLOCKHASH_RETRY_BACKOFFS_MS[attempt]) + ); +} /** * Default Solana RPC URL — BlockRun's multi-region Tatum-backed JSON-RPC @@ -645,23 +745,34 @@ export class SolanaLLMClient { body: Record ): Promise { const url = `${this.apiUrl}${endpoint}`; - const response = await this.fetchWithTimeout(url, { - method: "POST", - headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, - body: JSON.stringify(body), - }); + for (let staleRetries = 0; ; ) { + const response = await this.fetchWithTimeout(url, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, + body: JSON.stringify(body), + }); - if (response.status === 402) { - return this.handlePaymentAndRetry(url, body, response); - } + if (response.status === 402) { + try { + return await this.handlePaymentAndRetry(url, body, response); + } catch (error) { + if ( + !(error instanceof SafeStaleBlockhashError) || + staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length + ) throw error; + await waitForStaleRetry(staleRetries++); + continue; + } + } - if (!response.ok) { - let errorBody: unknown; - try { errorBody = await response.json(); } catch { errorBody = { error: "Request failed" }; } - throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)); - } + if (!response.ok) { + let errorBody: unknown; + try { errorBody = await response.json(); } catch { errorBody = { error: "Request failed" }; } + throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)); + } - return response.json() as Promise; + return response.json() as Promise; + } } private async handlePaymentAndRetry( @@ -731,6 +842,9 @@ export class SolanaLLMClient { }); if (retryResponse.status === 402) { + if (await isSafeStaleBlockhashResponse(retryResponse)) { + throw new SafeStaleBlockhashError(); + } throw new PaymentError("Payment was rejected. Check your Solana USDC balance."); } @@ -752,23 +866,34 @@ export class SolanaLLMClient { body: Record ): Promise> { const url = `${this.apiUrl}${endpoint}`; - const response = await this.fetchWithTimeout(url, { - method: "POST", - headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, - body: JSON.stringify(body), - }); + for (let staleRetries = 0; ; ) { + const response = await this.fetchWithTimeout(url, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, + body: JSON.stringify(body), + }); - if (response.status === 402) { - return this.handlePaymentAndRetryRaw(url, body, response); - } + if (response.status === 402) { + try { + return await this.handlePaymentAndRetryRaw(url, body, response); + } catch (error) { + if ( + !(error instanceof SafeStaleBlockhashError) || + staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length + ) throw error; + await waitForStaleRetry(staleRetries++); + continue; + } + } - if (!response.ok) { - let errorBody: unknown; - try { errorBody = await response.json(); } catch { errorBody = { error: "Request failed" }; } - throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)); - } + if (!response.ok) { + let errorBody: unknown; + try { errorBody = await response.json(); } catch { errorBody = { error: "Request failed" }; } + throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)); + } - return response.json() as Promise>; + return response.json() as Promise>; + } } private async handlePaymentAndRetryRaw( @@ -838,6 +963,9 @@ export class SolanaLLMClient { }); if (retryResponse.status === 402) { + if (await isSafeStaleBlockhashResponse(retryResponse)) { + throw new SafeStaleBlockhashError(); + } throw new PaymentError("Payment was rejected. Check your Solana USDC balance."); } @@ -861,22 +989,33 @@ export class SolanaLLMClient { const query = params ? "?" + new URLSearchParams(params).toString() : ""; const url = `${this.apiUrl}${endpoint}${query}`; - const response = await this.fetchWithTimeout(url, { - method: "GET", - headers: { "User-Agent": USER_AGENT }, - }); + for (let staleRetries = 0; ; ) { + const response = await this.fetchWithTimeout(url, { + method: "GET", + headers: { "User-Agent": USER_AGENT }, + }); - if (response.status === 402) { - return this.handleGetPaymentAndRetryRaw(url, endpoint, params, response); - } + if (response.status === 402) { + try { + return await this.handleGetPaymentAndRetryRaw(url, endpoint, params, response); + } catch (error) { + if ( + !(error instanceof SafeStaleBlockhashError) || + staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length + ) throw error; + await waitForStaleRetry(staleRetries++); + continue; + } + } - if (!response.ok) { - let errorBody: unknown; - try { errorBody = await response.json(); } catch { errorBody = { error: "Request failed" }; } - throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)); - } + if (!response.ok) { + let errorBody: unknown; + try { errorBody = await response.json(); } catch { errorBody = { error: "Request failed" }; } + throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)); + } - return response.json() as Promise>; + return response.json() as Promise>; + } } private async handleGetPaymentAndRetryRaw( @@ -947,6 +1086,9 @@ export class SolanaLLMClient { }); if (retryResponse.status === 402) { + if (await isSafeStaleBlockhashResponse(retryResponse)) { + throw new SafeStaleBlockhashError(); + } throw new PaymentError("Payment was rejected. Check your Solana USDC balance."); } diff --git a/test/unit/solana-safe-resign.test.ts b/test/unit/solana-safe-resign.test.ts new file mode 100644 index 0000000..9b9fb22 --- /dev/null +++ b/test/unit/solana-safe-resign.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { isSafeStaleBlockhashResponse } from "../../src/solana-client"; + +function rejection(body: Record, headers?: HeadersInit): Response { + return new Response(JSON.stringify(body), { + status: 402, + headers: { "content-type": "application/json", ...headers }, + }); +} + +describe("Solana safe stale-blockhash re-sign classification", () => { + it.each([ + { + code: "PAYMENT_INVALID", + reason: "expired_signature", + error: "Payment verification failed", + }, + { + code: "PAYMENT_INVALID", + invalidMessage: "BlockhashNotFound", + error: { message: "Payment verification failed" }, + }, + { + code: "PAYMENT_BLOCKHASH_STALE", + error: "Payment verification failed", + }, + ])("accepts an explicit verification-phase stale signal", async (body) => { + await expect(isSafeStaleBlockhashResponse(rejection(body))).resolves.toBe(true); + }); + + it.each([ + { code: "PAYMENT_BLOCKHASH_STALE" }, + { invalidMessage: "BlockhashNotFound" }, + { debug: "transaction_simulation_failed" }, + { + code: "SETTLEMENT_FAILED", + reason: "expired_signature", + error: "Payment settlement failed", + }, + { + code: "PAYMENT_BLOCKHASH_STALE", + invalidMessage: "BlockhashNotFound", + error: { message: "Payment settlement failed" }, + }, + { + code: "PAYMENT_INVALID", + reason: "insufficient_funds", + error: "Payment verification failed", + }, + ])("rejects settlement, terminal, and phase-ambiguous signals", async (body) => { + await expect(isSafeStaleBlockhashResponse(rejection(body))).resolves.toBe(false); + }); + + it("rejects oversized or malformed failure bodies", async () => { + await expect( + isSafeStaleBlockhashResponse( + new Response("{}", { status: 402, headers: { "content-length": "70000" } }) + ) + ).resolves.toBe(false); + await expect( + isSafeStaleBlockhashResponse(new Response("not json", { status: 402 })) + ).resolves.toBe(false); + await expect( + isSafeStaleBlockhashResponse( + new Response("x".repeat(70_000), { status: 402 }) + ) + ).resolves.toBe(false); + }); +});