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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.52.1
0.52.2
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 18 additions & 7 deletions src/tools/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/utils/solana-402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion test/quote-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
21 changes: 16 additions & 5 deletions test/solana-rail-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading