From 6e43843d01e60fbd0d40dc149c457cd6e8bdb8a6 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:20:21 +0800 Subject: [PATCH] fix(image): poll the Solana job instead of losing a paid render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blockrun_image on the Solana wallet rail sent one POST and never polled. Past the gateway's ~30s inline window the route answers 202 { id, poll_url } instead of the image, so the tool returned "No image URL in response" — and the charge stood, because Solana settles at submit and cannot settle after a long render (a signed transaction expires with its blockhash). The user paid and got nothing. Observed live: google/nano-banana-pro at 4096x4096 booked $0.1575 and returned no image. The same defect was fixed on the account rail in #140; the Solana wallet rail was never moved across. It now uses solanaPaidAsyncPost, the helper video and music already use, which handles the inline 200 and the 202 alike and re-signs each poll. Also fixes a hazard that switch exposed: solanaPaidAsyncPost marked the payment tracker answered as soon as the submit response arrived, including a 502/503/504 from the edge. On this rail the submit IS the paid request, so an origin that never answered may still have settled it — and the tool said "temporary API issue, try again" on a charge that already stood. An edge status is no longer the origin's verdict; the tracker stays armed and the tool books it as a precaution. Video and music get the same protection. Two test doubles were letting this through and are repaired rather than relaxed: quote-guard matched the old helper name, and the async helper's stand-in never fired onPaidRequest, so the tracker it exists to exercise was never armed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ VERSION | 2 +- package.json | 2 +- src/tools/image.ts | 25 ++++++++++++++++++------- src/utils/solana-402.ts | 9 ++++++++- test/quote-guard.test.ts | 5 ++++- test/solana-rail-parity.test.ts | 21 ++++++++++++++++----- 7 files changed, 75 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4057071..fc0d50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.52.2 + +### Fixed — a paid image could be lost on the Solana wallet rail + +`blockrun_image` on Solana sent one POST and never polled. Past the gateway's +~30s inline window the route answers `202 { id, poll_url }` instead of the +image, so the tool returned "No image URL in response" — **and the charge +stood**, because Solana settles at submit and cannot settle after a long render +(a signed transaction expires with its blockhash). The user paid and got +nothing. + +Observed live on 2026-09-29: `google/nano-banana-pro` at 4096x4096 booked +$0.1575 and returned no image. The same defect was fixed on the account rail in +#140; the Solana wallet rail was never moved across. It now uses +`solanaPaidAsyncPost` — the helper video and music already use, which handles +the inline 200 and the 202 alike and re-signs each poll with a fresh blockhash. + +### Fixed — an edge 5xx on a Solana submit is no longer reported as free + +`solanaPaidAsyncPost` marked the payment tracker answered as soon as the submit +response arrived, including a 502/503/504 from the edge. On this rail the submit +*is* the paid request, so an origin that never answered may still have settled +it — and the tool told the user "temporary API issue, try again" on a charge +that already stood. An edge status is no longer treated as the origin's verdict; +the tracker stays armed and the tool books it as a precaution. Applies to video +and music as well. + All notable changes to BlockRun MCP will be documented in this file. ## 0.52.1 diff --git a/VERSION b/VERSION index 27d68a6..3f01561 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.1 +0.52.2 diff --git a/package.json b/package.json index a5e98e6..adc5de3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.52.1", + "version": "0.52.2", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Pay per call from a USDC wallet (Solana or Base) or a BlockRun API key.", "type": "module", diff --git a/src/tools/image.ts b/src/tools/image.ts index 504fad4..2373e51 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -13,7 +13,7 @@ import { getChain, getImageClient } from "../utils/wallet.js"; import { isApiKeyMode } from "../utils/auth.js"; import { apiKeyAsyncPost, BilledJobError } from "../utils/api-key-call.js"; import { ledgerFallback } from "../utils/raw-call.js"; -import { solanaPaidPost } from "../utils/solana-402.js"; +import { solanaPaidAsyncPost } from "../utils/solana-402.js"; import { isBlockedFetchHostResolved } from "../utils/ssrf.js"; import { shouldInline, buildInlineImageBlock } from "../utils/inline-image.js"; import { confirmSpend } from "../utils/confirm-spend.js"; @@ -564,13 +564,24 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil image: normalizedImage, mask: normalizedMask, }); - // The quote, captured for the tracker: armed at the helper's - // onPaidRequest (the line before the signed POST leaves) and - // settled at onPaidResponse (any status), so the unpaid probe - // and the signing step are outside the window and an answered - // 5xx is never a maybe. + // solanaPaidAsyncPost, not solanaPaidPost: past its 30s inline + // window the Solana gateway answers 202 + poll_url, and the + // single-POST helper handed that envelope back as if it were the + // image — "No image URL in response" while the charge stood, + // because Solana settles at SUBMIT and cannot settle later (a + // signed transaction expires with its blockhash). So the user paid + // and lost the render. Observed live on 2026-09-29: + // nano-banana-pro at 4096x4096 booked $0.1575 and returned nothing. + // + // This is the same defect #140 fixed on the account rail; the + // Solana wallet rail was never moved across. The async helper + // handles the inline 200 and the 202 alike and re-signs each poll + // with a fresh blockhash, which is what the poll GET needs. let solQuotedUsd: number | null = null; - const { data, paidUsd } = await solanaPaidPost(endpoint, body, SOLANA_IMAGE_TIMEOUT_MS, { + const { data, paidUsd } = await solanaPaidAsyncPost(endpoint, body, { + pollBudgetMs: SOLANA_IMAGE_TIMEOUT_MS, + what: action === "edit" ? "Image edit" : "Image generation", + tool: "blockrun_image", onPaidRequest: () => paid.arm(solQuotedUsd), onPaidResponse: () => paid.settle(), // The Solana gateway prices carry a markup over the Base estimate diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index 50d67d9..cbc3dea 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -464,7 +464,14 @@ export async function solanaPaidAsyncPost( headers: { "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentPayload }, body: JSON.stringify(body), }, submitTimeout); - opts.onPaidResponse?.(); + // An EDGE status is not the origin's verdict: 502/503/504 mean a proxy + // answered for an origin that may still be running — and on this rail the + // submit IS the paid request, so the origin may already have settled it. + // Leaving the tracker armed is what makes the tool book it as a precaution; + // calling settle() here would report "temporary API issue, try again" on a + // charge that already stands. Every other status is a real answer. + const edgeAnswered = submitResp.status === 502 || submitResp.status === 503 || submitResp.status === 504; + if (!edgeAnswered) opts.onPaidResponse?.(); if (submitResp.status === 402) { await submitResp.json().catch(() => ({})); throw paidRequestRefused(settleFailureReason(submitResp), "at submit"); diff --git a/test/quote-guard.test.ts b/test/quote-guard.test.ts index b9413f7..04af207 100644 --- a/test/quote-guard.test.ts +++ b/test/quote-guard.test.ts @@ -87,6 +87,9 @@ test("image.ts actually calls the guard on the rail that has a quote", async () const { readFileSync } = await import("node:fs"); const src = readFileSync(new URL("../src/tools/image.ts", import.meta.url), "utf8"); // The Solana helper is the only image rail that surfaces a 402 amount. - assert.match(src, /solanaPaidPost\([\s\S]{0,900}onQuote:/, "image must guard the Solana quote"); + // Matches solanaPaidPost or solanaPaidAsyncPost: image moved to the async + // helper on 2026-09-29 (the sync one dropped a 202 and lost a paid render), + // and the guard has to hold on whichever one the tool calls. + assert.match(src, /solanaPaid(?:Async)?Post\([\s\S]{0,900}onQuote:/, "image must guard the Solana quote"); assert.match(src, /assertQuoteNearEstimate\(/, "image must call the shared guard"); }); diff --git a/test/solana-rail-parity.test.ts b/test/solana-rail-parity.test.ts index 022043a..e0e426a 100644 --- a/test/solana-rail-parity.test.ts +++ b/test/solana-rail-parity.test.ts @@ -59,12 +59,22 @@ mock.module("../src/utils/auth.js", { }); mock.module("../src/utils/solana-402.js", { namedExports: { - // The async helper (video, music): hands the caller the authoritative - // quote BEFORE signing, then either finishes or gives up on its deadline - // with the helper's own "a poll still in flight can settle" wording. - solanaPaidAsyncPost: async (_e: string, _b: unknown, opts: { onQuote?: (usd: number | null, d?: unknown) => void }) => { + // The async helper (video, music, and image since 2026-09-29): hands the + // caller the authoritative quote BEFORE signing, then either finishes or + // gives up on its deadline with the helper's own "a poll still in flight + // can settle" wording. + // + // It fires onPaidRequest the line before the signed submit leaves and + // onPaidResponse when a non-edge answer arrives (src/utils/solana-402.ts + // lines 461/474) — the same tracker seam the sync helper honours below. + // A stand-in that skipped onPaidRequest left the tracker unarmed, so a + // tool that books through it reported a free failure for a give-up after + // the transfer was signed. That is the exact bug this test exists to catch, + // so the double has to arm. + solanaPaidAsyncPost: async (_e: string, _b: unknown, opts: { onQuote?: (usd: number | null, d?: unknown) => void; onPaidRequest?: () => void; onPaidResponse?: () => void }) => { onQuoteWasFunction = typeof opts.onQuote === "function"; opts.onQuote?.(quoteUsd, { resource: { description: "Seedance 2.0 Pro video generation (5s)" } }); + opts.onPaidRequest?.(); paidPostsIssued++; if (giveUp) { throw new Error( @@ -73,9 +83,10 @@ mock.module("../src/utils/solana-402.js", { "wallet's recent transactions before retrying.", ); } + opts.onPaidResponse?.(); return { data: happyBody, paidUsd: quoteUsd, txHash: "sol-tx" }; }, - // The synchronous helper (speech, image, realface): same hook, and on a + // The synchronous helper (speech, realface): same hook, and on a // give-up the paid POST itself aborts after the transfer was signed. The // real helper fires onPaidRequest the line before the signed POST leaves // and onPaidResponse when any answer arrives — the seam the tools arm