From 680f79490a01750a9262c67c97f47229e441dd11 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:03:19 -0500 Subject: [PATCH 1/3] feat(solana): poll media jobs with a wallet ownership proof, not a payment sol.blockrun.ai (rev 00561-ghx, 2026-10-10) accepts x-poll-wallet / x-poll-timestamp / x-poll-signature on the video, audio and image poll routes: an ed25519 signature by the submitting wallet over "blockrun-poll:v1:::". solanaPaidAsyncPost now polls with that proof instead of re-signing an SPL payment every 20s. - video: pending polls carry the proof only; the finished job's 402 (body status "completed") is the challenge for the ONE payment, pinned to the submit's amount/recipient/fee payer, then re-sent on the existing payment-signed path. - music/image: paid at submit; polls never sign a payment. - a refused proof (video 400 "Invalid job id", audio/image 404 "Job not found") or a non-completion 402 falls back once to payment-signed polling, logged to stderr. - Base rail and the account rail are unchanged. Signing is node:crypto ed25519 over the session keypair's seed; bs58 (already installed via @blockrun/llm) is now a declared dependency. --- package-lock.json | 1 + package.json | 1 + src/tools/image.ts | 6 +- src/tools/music.ts | 8 +- src/tools/video.ts | 8 +- src/utils/poll-ownership.ts | 113 +++++++++++ src/utils/solana-402.ts | 169 ++++++++++++++-- test/solana-402-async.test.ts | 6 +- test/solana-poll-ownership.test.ts | 314 +++++++++++++++++++++++++++++ test/video-money-path.test.ts | 24 ++- 10 files changed, 623 insertions(+), 27 deletions(-) create mode 100644 src/utils/poll-ownership.ts create mode 100644 test/solana-poll-ownership.test.ts diff --git a/package-lock.json b/package-lock.json index 56b04af..ffe9049 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "axios": "^1.18.1", + "bs58": "^6.0.0", "https-proxy-agent": "^9.1.0", "open": "^11.0.0", "qrcode": "^1.5.4", diff --git a/package.json b/package.json index d7338a2..e4ad3c1 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "axios": "^1.18.1", + "bs58": "^6.0.0", "https-proxy-agent": "^9.1.0", "open": "^11.0.0", "qrcode": "^1.5.4", diff --git a/src/tools/image.ts b/src/tools/image.ts index 1e6fe3a..d121817 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -584,8 +584,10 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // // 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. + // handles the inline 200 and the 202 alike, and polls with the + // wallet's ownership proof (utils/poll-ownership.ts) — no payment + // is signed on an image poll; a gateway that refuses the proof gets + // payment-signed polls with a fresh blockhash instead. let solQuotedUsd: number | null = null; const { data, paidUsd } = await solanaPaidAsyncPost(endpoint, body, { pollBudgetMs: SOLANA_IMAGE_TIMEOUT_MS, diff --git a/src/tools/music.ts b/src/tools/music.ts index 02e2c9b..d854f10 100644 --- a/src/tools/music.ts +++ b/src/tools/music.ts @@ -241,9 +241,11 @@ Returns a permanent BlockRun-hosted URL.`, // helper signs next (see video.ts for the residual window). paid.arm(solQuotedUsd); }, - // Exact edges of every signed request — submit and each poll — so - // a deadline reached with the last poll answered books nothing and - // one reached with a poll in flight books the quote (C32/C37). + // Exact edges of every payment-signed request — the submit, and a + // poll only if the gateway refused the ownership proof and the + // helper fell back to payment-signed polling (utils/solana-402.ts) + // — so a deadline reached with the last poll answered books nothing + // and one reached with a poll in flight books the quote (C32/C37). onPaidRequest: () => { paidRequestSent = true; paid.arm(quotedUsd); }, onPaidResponse: () => paid.settle(), }); diff --git a/src/tools/video.ts b/src/tools/video.ts index 963889e..91714b0 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -50,7 +50,8 @@ export const VIDEO_POLL_TIMEOUT_MS = 60_000; // rather than a literal restated in the test. export const VIDEO_PAYMENT_AUTH_SECONDS = 600; // Solana has no EIP-3009 validBefore to stay inside: utils/solana-402.ts -// re-signs the SPL transaction with a fresh blockhash throughout polling, so +// polls with the wallet's ownership proof (no payment) and signs the one SPL +// payment only when the gateway reports the video ready, so // the budget is bounded by the tool's patience, not an authorization. Measured // from the helper's entry (quote + submit + polling), so the description's // "15 min Solana hard cap" is a true total, not a polling budget that starts @@ -847,7 +848,10 @@ Returns a permanent blockrun-hosted video URL (the gateway mirrors the asset to paid.arm(solQuotedUsd); }, // The edges of every request carrying PAYMENT-SIGNATURE — the - // submit and each poll. A poll that drops leaves the tracker + // submit and the poll that settles the finished video (plus + // every poll after a fallback to payment-signed polling; the + // ownership-proof polls carry no payment and fire neither + // hook). A poll that drops leaves the tracker // armed; a poll that answers "in_progress" settles it, so a // deadline reached with nothing outstanding books nothing, the // way Base's loop already behaved. Before these hooks the rail diff --git a/src/utils/poll-ownership.ts b/src/utils/poll-ownership.ts new file mode 100644 index 0000000..b02a0db --- /dev/null +++ b/src/utils/poll-ownership.ts @@ -0,0 +1,113 @@ +// src/utils/poll-ownership.ts +// Proof of job ownership for the Solana gateway's async media polls, signed by +// the wallet that paid the submit — WITHOUT a payment. +// +// Every media poll on sol.blockrun.ai used to need an x402 payment header as +// its proof of identity. On Solana a signed payment is a transaction pinned to +// a recent blockhash (~60-90s of life), so a multi-minute video job meant +// signing a fresh payment every ~20s. Since 2026-10-10 (blockrun-sol +// src/lib/poll-ownership.ts, revision 00561-ghx) the poll routes also accept +// a short ed25519 signature over the job id instead: +// +// message "blockrun-poll:v1:::" +// headers x-poll-wallet base58 32-byte public key of the submitting wallet +// x-poll-timestamp the in the message (±300s skew) +// x-poll-signature base58 64-byte detached ed25519 signature +// +// Nothing here can move money: it signs a text message, not a transaction. +// The gateway compares the proven wallet to the payer bound into the job. +// +// Pure node:crypto + bs58, no @blockrun/llm import, so the signature can be +// checked in tests against the real verifier without any module mocks. +import { createPrivateKey, createPublicKey, sign as ed25519Sign, type KeyObject } from "node:crypto"; +import bs58 from "bs58"; + +/** The poll route's ; music polls are "audio" on the wire. */ +export type PollKind = "video" | "audio" | "image"; + +export const POLL_AUTH_HEADERS = { + wallet: "x-poll-wallet", + timestamp: "x-poll-timestamp", + signature: "x-poll-signature", +} as const; + +export function pollAuthMessage(kind: PollKind, jobId: string, timestampS: number): string { + return `blockrun-poll:v1:${kind}:${jobId}:${timestampS}`; +} + +// The poll routes that verify the proof, keyed by the path the gateway hands +// back as poll_url. Derived from the POLL path, not the submit endpoint: an +// image edit is submitted to /v1/images/image2image and polled on +// /api/v1/images/generations/{id}, whose route verifies kind "image". +const POLL_ROUTES: Array<[RegExp, PollKind]> = [ + [/\/v1\/videos\/generations\/([^/]+)$/, "video"], + [/\/v1\/audio\/generations\/([^/]+)$/, "audio"], + [/\/v1\/images\/generations\/([^/]+)$/, "image"], +]; + +/** + * The kind and job id the gateway will verify for this poll URL, or null for a + * route that does not accept the proof (the caller keeps paid polling). + * The job id is the decoded path segment — the exact string the route signs + * against — which is the submit response's `id` as returned. + */ +export function pollRouteFor(pollUrl: URL): { kind: PollKind; jobId: string } | null { + for (const [pattern, kind] of POLL_ROUTES) { + const match = pollUrl.pathname.match(pattern); + if (!match) continue; + try { + const jobId = decodeURIComponent(match[1]); + return jobId ? { kind, jobId } : null; + } catch { + return null; + } + } + return null; +} + +// RFC 8410 PKCS#8 prefix for a raw 32-byte Ed25519 seed. +const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +export interface PollSigner { + /** base58 public key the signatures verify under. */ + wallet: string; + /** Fresh x-poll-* headers for one poll (sign per poll: the timestamp is in the message). */ + headers(kind: PollKind, jobId: string, nowMs?: number): Record; +} + +/** + * A signer over a Solana keypair's 64-byte secret key (seed ‖ public key, the + * .solana-session format). The key material is held only inside a KeyObject + * and never leaves this closure. Throws on a malformed key — callers treat + * that as "no ownership proof" and keep paid polling. + */ +export function createPollSigner(secretKey: Uint8Array): PollSigner { + if (secretKey.length !== 64 && secretKey.length !== 32) { + throw new Error("Solana secret key must be 32 or 64 bytes"); + } + const seed = Buffer.from(secretKey.subarray(0, 32)); + const privateKey: KeyObject = createPrivateKey({ + key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]), + format: "der", + type: "pkcs8", + }); + seed.fill(0); + // The wallet is DERIVED from the seed, so the signature always verifies + // under the key we present; for a well-formed keypair it is also bytes + // 32..63 — the address that paid the submit. + const jwk = createPublicKey(privateKey).export({ format: "jwk" }) as { x?: string }; + if (!jwk.x) throw new Error("Could not derive the Solana public key"); + const wallet = bs58.encode(Buffer.from(jwk.x, "base64url")); + return { + wallet, + headers(kind, jobId, nowMs = Date.now()) { + const timestamp = Math.floor(nowMs / 1000); + const signature = ed25519Sign(null, Buffer.from(pollAuthMessage(kind, jobId, timestamp), "utf8"), privateKey); + return { + [POLL_AUTH_HEADERS.wallet]: wallet, + [POLL_AUTH_HEADERS.timestamp]: String(timestamp), + [POLL_AUTH_HEADERS.signature]: bs58.encode(signature), + }; + }, + }; +} diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index ef5abd7..51be0d0 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -25,6 +25,23 @@ // Until audit round 3 the helper assumed the first model for every route and // said "No payment was taken" for a failed music job the gateway had already // settled. +// +// How the polls prove who is asking. Since 2026-10-10 the Solana poll routes +// accept an ownership proof — an ed25519 signature over the job id by the +// wallet that paid the submit (utils/poll-ownership.ts) — in place of a +// payment signature. The helper polls with that proof, so a pending job costs +// no transaction signatures at all: +// +// video pending polls carry the proof; the finished job answers 402 with +// the x402 challenge and body status "completed", and the helper +// signs ONE payment from it and re-sends the poll with it. +// music, paid at submit; the proof alone delivers the result and no poll +// image ever signs a payment. +// +// A refusal of the proof for the job this client just submitted ("Invalid job +// id" 400 on video, "Job not found" 404 on audio/image), or a 402 that is not +// the video completion challenge, switches the job ONCE to the payment-signed +// polling below, which every gateway revision still accepts. import { SolanaLLMClient, PaymentError, @@ -40,6 +57,7 @@ import { JobFailedError, pollTimeoutFor } from "./poll.js"; import { resolveSolanaKey } from "./wallet.js"; import { amountToUsd } from "./budget.js"; import { BilledJobError } from "./api-key-call.js"; +import { createPollSigner, pollRouteFor, type PollSigner } from "./poll-ownership.js"; const QUOTE_TIMEOUT_MS = 15_000; @@ -244,6 +262,14 @@ export interface SolanaPaidAsyncPostOptions extends PaidRequestHooks { resignIntervalMs?: number; /** Maximum reactive re-signs after a completed poll rejects a stale signature. Defaults to SOLANA_ASYNC_MAX_REACTIVE_RESIGNS. */ maxReactiveResigns?: number; + /** + * Poll with the wallet's x-poll-* ownership proof (utils/poll-ownership.ts) + * instead of a payment signature, on the poll routes that accept it. + * Default true. False keeps the payment-signed polling this helper did + * before the gateway shipped the proof — the same path it falls back to + * when the gateway refuses the proof. + */ + pollOwnership?: boolean; /** * Called after the authoritative quote is parsed and before anything is * signed. `details` is the decoded 402 (amount, recipient, resource @@ -258,10 +284,13 @@ type SolanaPaymentContext = { paidUsd: number | null; }; -async function readPaymentRequired(response: Response): Promise { +/** `preReadBody`: the body, when the caller already consumed it (a Response body reads once). */ +async function readPaymentRequired(response: Response, preReadBody?: Record | null): Promise { let header = response.headers.get("payment-required") || response.headers.get("PAYMENT-REQUIRED"); if (!header) { - const body = await response.json().catch(() => null) as Record | null; + const body = preReadBody !== undefined + ? preReadBody + : await response.json().catch(() => null) as Record | null; if (body && (body.accepts || body.x402Version)) { header = Buffer.from(JSON.stringify(body)).toString("base64"); } @@ -546,6 +575,33 @@ export async function solanaPaidAsyncPost( // this client gives up; one that was answered cannot. let paidPollInFlight = false; + // Ownership-proof polling (module comment). Null route or signer = the + // payment-signed polling below from the first poll. A key that will not + // load here is not an error: the same key just signed the submit, and the + // paid path reports any real fault precisely. + const pollRoute = opts.pollOwnership === false ? null : pollRouteFor(pollUrl); + let pollSigner: PollSigner | null = null; + if (pollRoute) { + try { + pollSigner = createPollSigner(await solanaKeyToBytes(privateKey)); + } catch { + pollSigner = null; + } + } + let ownershipPolling = pollSigner !== null; + // Send the next poll without the interval sleep: the completion payment and + // the first payment-signed poll after a fallback answer a response that + // just arrived, not a job that needs more time. + let pollNow = false; + const fallBackToPaidPolling = (why: string) => { + ownershipPolling = false; + pollNow = true; + // The submit's signature is as old as the job by now: re-sign first. + nextResignAt = 0; + // stderr is the MCP log channel; only the job id and the reason, never key material. + console.error(`[blockrun] ${what}: ${why}; polling job ${pollRoute?.jobId ?? jobId ?? "?"} with payment signatures instead.`); + }; + // EVERY escape from here on goes through one classifier, not only the ones // enumerated below. Round 3 typed the failed-job, poll-error and deadline // exits and left the reactive re-sign path throwing plain Errors — a @@ -566,7 +622,73 @@ export async function solanaPaidAsyncPost( async function pollUntilDone(): Promise { while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + if (!pollNow) await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + pollNow = false; + + if (ownershipPolling && pollSigner && pollRoute) { + const pollTimeout = pollTimeoutFor(deadline, Date.now(), pollTimeoutMs); + if (pollTimeout === 0) break; + let ownedResp: Response; + try { + // Carries no payment: nothing armed, nothing that can settle. Signed + // fresh per poll — the timestamp is inside the signed message. + ownedResp = await fetchWithTimeout(pollUrl.toString(), { + method: "GET", + headers: pollSigner.headers(pollRoute.kind, pollRoute.jobId), + }, pollTimeout); + } catch { + continue; + } + const ownedData = await ownedResp.json().catch(() => null) as Record | null; + + if (ownedResp.status === 402) { + if (pollRoute.kind === "video" && ownedData?.status === "completed") { + // The video is ready and unpaid: this 402 is the challenge for the + // ONE payment that settles it. Pinned to the submit's quote like + // every re-sign — a finished job is no reason to pay more, or + // someone else. + const fresh = parseSolanaChallenge(await readPaymentRequired(ownedResp, ownedData)); + const originalFeePayer = (original.details.extra as { feePayer?: string } | undefined)?.feePayer; + const freshFeePayer = (fresh.details.extra as { feePayer?: string } | undefined)?.feePayer; + if ( + String(fresh.details.amount) !== String(original.details.amount) || + fresh.details.recipient !== original.details.recipient || + freshFeePayer !== originalFeePayer + ) { + throw giveUp("The completed job's payment challenge changed the payment amount, recipient or fee payer; refusing to authorize it.", "No charge was made."); + } + lastStatus = "completed"; + const signedAt = Date.now(); + try { + paymentPayload = await signSolanaChallenge(fresh, pollUrl.toString(), privateKey); + } catch { + // Nothing left the machine. The next proof poll gets the same + // challenge; the deadline bounds the retries. + continue; + } + nextResignAt = signedAt + resignIntervalMs; + // From here the job is on the payment-signed path below, which + // settles it and handles every settle answer. + ownershipPolling = false; + pollNow = true; + continue; + } + fallBackToPaidPolling("the gateway answered the ownership-proof poll with a payment challenge"); + continue; + } + + const refused = + (pollRoute.kind === "video" && ownedResp.status === 400 && ownedData?.error === "Invalid job id") || + (pollRoute.kind !== "video" && ownedResp.status === 404 && ownedData?.error === "Job not found"); + if (refused) { + fallBackToPaidPolling(`the gateway refused the ownership proof (HTTP ${ownedResp.status} ${String(ownedData?.error)})`); + continue; + } + + const outcome = classifyPollAnswer(ownedResp, ownedData ?? {}); + if (outcome) return outcome; + continue; + } // Proactively refresh only the transaction/blockhash. The authorized // amount, recipient and fee payer remain pinned to the original 402. @@ -662,6 +784,31 @@ export async function solanaPaidAsyncPost( } const pollData = await pollResp.json().catch(() => ({})) as Record; + const outcome = classifyPollAnswer(pollResp, pollData); + if (outcome) return outcome; + } + + // The deadline says what this helper KNOWS, and the hooks above let the + // caller's tracker know the same thing: a poll that was still in flight can + // settle server-side (the gateway does not stop because we hung up); a poll + // that was answered "in_progress" cannot, because on this model settlement + // needs a signed poll to observe "completed". An ownership-proof poll + // carries no payment, so it is never the one in flight. + const deadlineCore = `${what} did not complete within ${Math.round(pollBudgetMs / 1000)}s (last status: ${lastStatus}).`; + if (settledAtSubmit) throw giveUp(deadlineCore, ""); + throw new Error( + paidPollInFlight + ? `${deadlineCore} No settlement receipt was observed by this client, but a poll carrying the payment signature was still in flight at the deadline and can settle server-side, so check the wallet's recent transactions before retrying. ${reclaimNote}` + : `${deadlineCore} The last poll was answered and no request carrying the payment signature is outstanding, so no charge was made. ${reclaimNote}`, + ); + } + + /** + * Every non-402 poll answer, from either kind of poll: the result to return, + * undefined to poll again, or a throw. One classifier so the proof polls and + * the payment-signed polls cannot drift on what "done" and "failed" mean. + */ + function classifyPollAnswer(pollResp: Response, pollData: Record): SolanaPaidPostResult | undefined { if (typeof pollData.status === "string") lastStatus = pollData.status; // A receipt header IS settlement — the USDC moved the moment the gateway @@ -686,23 +833,9 @@ export async function solanaPaidAsyncPost( if (pollResp.ok && lastStatus === "completed") { return { data: pollData, paidUsd: original.paidUsd, txHash: undefined, jobId }; } - if (pollResp.status === 202 || pollResp.status === 504 || pollResp.ok) continue; + if (pollResp.status === 202 || pollResp.status === 504 || pollResp.ok) return undefined; const pollError = `${what} poll error ${pollResp.status}: ${JSON.stringify(pollData)}`; if (settledAtSubmit) throw giveUp(pollError, ""); throw new Error(pollError); } - - // The deadline says what this helper KNOWS, and the hooks above let the - // caller's tracker know the same thing: a poll that was still in flight can - // settle server-side (the gateway does not stop because we hung up); a poll - // that was answered "in_progress" cannot, because on this model settlement - // needs a signed poll to observe "completed". - const deadlineCore = `${what} did not complete within ${Math.round(pollBudgetMs / 1000)}s (last status: ${lastStatus}).`; - if (settledAtSubmit) throw giveUp(deadlineCore, ""); - throw new Error( - paidPollInFlight - ? `${deadlineCore} No settlement receipt was observed by this client, but a poll carrying the payment signature was still in flight at the deadline and can settle server-side, so check the wallet's recent transactions before retrying. ${reclaimNote}` - : `${deadlineCore} The last poll was answered and no request carrying the payment signature is outstanding, so no charge was made. ${reclaimNote}`, - ); - } } diff --git a/test/solana-402-async.test.ts b/test/solana-402-async.test.ts index ccd2029..34ab264 100644 --- a/test/solana-402-async.test.ts +++ b/test/solana-402-async.test.ts @@ -99,7 +99,11 @@ const paymentResponse = (errorReason: string) => Buffer.from(JSON.stringify({ su const settleFail402 = (errorReason = "Transaction simulation failed: Blockhash not found") => ({ status: 402, ok: false, headers: headers({ "payment-response": paymentResponse(errorReason) }), json: async () => ({ error: "Payment settlement failed" }) }); const challenge402 = () => ({ status: 402, ok: false, headers: headers({ "payment-required": "fresh" }), json: async () => ({}) }); -const fast = { pollBudgetMs: 10_000, pollIntervalMs: 1, resignIntervalMs: 60_000 }; +// pollOwnership:false — this suite pins the payment-signed polling path, which +// is the fallback when the gateway refuses the ownership proof and still the +// path every settling video poll takes. The proof path has its own suite, +// test/solana-poll-ownership.test.ts. +const fast = { pollBudgetMs: 10_000, pollIntervalMs: 1, resignIntervalMs: 60_000, pollOwnership: false }; const gets = () => requests.filter((r) => r.method === "GET"); const posts = () => requests.filter((r) => r.method === "POST"); diff --git a/test/solana-poll-ownership.test.ts b/test/solana-poll-ownership.test.ts new file mode 100644 index 0000000..9c0136d --- /dev/null +++ b/test/solana-poll-ownership.test.ts @@ -0,0 +1,314 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// Ownership-proof polling on the Solana wallet rail (utils/poll-ownership.ts, +// utils/solana-402.ts). The gateway contract is blockrun-sol +// src/lib/poll-ownership.ts (live 2026-10-10): pending polls carry x-poll-* +// instead of a payment, a finished video answers 402 for the ONE payment that +// settles it, music and image polls never sign a payment, and a refused proof +// falls back once to payment-signed polling. +// +// The signature is checked with node:crypto's own ed25519 verifier against the +// exact message the gateway rebuilds — not against this module's output. +import { test, mock, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { createPublicKey, generateKeyPairSync, verify } from "node:crypto"; +import bs58 from "bs58"; + +// A real ed25519 keypair in the .solana-session layout: seed ‖ public key. +const { privateKey: kp, publicKey: kpub } = generateKeyPairSync("ed25519"); +const seed = Buffer.from((kp.export({ format: "jwk" }) as { d: string }).d, "base64url"); +const pub = Buffer.from((kpub.export({ format: "jwk" }) as { x: string }).x, "base64url"); +const SECRET = new Uint8Array(Buffer.concat([seed, pub])); +const WALLET = bs58.encode(pub); + +function headers(map: Record = {}) { + const lower = Object.fromEntries(Object.entries(map).map(([key, value]) => [key.toLowerCase(), value])); + return { get: (name: string) => lower[name.toLowerCase()] ?? null }; +} + +type Scripted = { url: string; method: string; headers: Record }; +let script: Array<() => unknown> = []; +let requests: Scripted[] = []; +let paymentsSigned = 0; +let failSignOnCall: number | null = null; +let signCalls = 0; +mock.module("../src/utils/http.js", { + namedExports: { + fetchWithTimeout: async (url: string, init: { method?: string; headers?: Record }) => { + requests.push({ url, method: init.method || "GET", headers: init.headers || {} }); + const next = script.shift(); + if (!next) throw new Error("UNEXPECTED_NETWORK_CALL"); + return next(); + }, + }, +}); +mock.module("../src/utils/wallet.js", { + namedExports: { + resolveSolanaKey: () => "test-solana-key", + solanaKeyUnavailableReason: () => undefined, + getApiBase: () => "https://blockrun.ai/api", + resolveGatewayUrl: (u: string) => u, + }, +}); +const baseDetails = () => ({ + network: "solana:mainnet", + recipient: "recipient", + amount: "500000", + extra: { feePayer: "fee-payer" }, + resource: { url: "https://sol.blockrun.ai/api/v1/videos/generations" }, +}); +let details: Record = baseDetails(); +class FakePaymentError extends Error { constructor(m: string) { super(m); this.name = "PaymentError"; } } +mock.module("@blockrun/llm", { + namedExports: { + SolanaLLMClient: { SOLANA_API_URL: "https://sol.blockrun.ai/api" }, + PaymentError: FakePaymentError, + SOLANA_NETWORK: "solana:mainnet", + solanaPublicKey: async () => WALLET, + solanaKeyToBytes: async () => SECRET, + // The payment-signer tripwire: every call is a signed SPL transfer. + createSolanaPaymentPayload: async () => { + signCalls++; + if (failSignOnCall === signCalls) throw new Error("rpc blip"); + paymentsSigned++; + return `signed-svm-payment-${paymentsSigned}`; + }, + parsePaymentRequired: () => ({}), + extractPaymentDetails: () => details, + }, +}); + +const { solanaPaidAsyncPost } = await import("../src/utils/solana-402.js"); +const { createPollSigner, pollAuthMessage, pollRouteFor, POLL_AUTH_HEADERS } = await import("../src/utils/poll-ownership.js"); +const { JobFailedError } = await import("../src/utils/poll.js"); + +const errLog = mock.method(console, "error", () => {}); +beforeEach(() => { + script = []; requests = []; paymentsSigned = 0; failSignOnCall = null; signCalls = 0; details = baseDetails(); + errLog.mock.resetCalls(); +}); +afterEach(() => { assert.equal(script.length, 0, "unconsumed scripted responses"); }); + +/** Independent check: the gateway's verifier, over the message it rebuilds. */ +function verifiesFor(h: Record, kind: string, jobId: string): boolean { + const wallet = h[POLL_AUTH_HEADERS.wallet]; + const ts = h[POLL_AUTH_HEADERS.timestamp]; + const sig = h[POLL_AUTH_HEADERS.signature]; + if (!wallet || !ts || !sig) return false; + const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(bs58.decode(wallet)).toString("base64url") }, format: "jwk" }); + return verify(null, Buffer.from(`blockrun-poll:v1:${kind}:${jobId}:${ts}`, "utf8"), key, Buffer.from(bs58.decode(sig))); +} +const hasProof = (r: Scripted) => POLL_AUTH_HEADERS.signature in r.headers; +const gets = () => requests.filter((r) => r.method === "GET"); + +const VIDEO_POLL = "/api/v1/videos/generations/vid_1?model=x&duration=5&sig=abc"; +const quote = () => ({ status: 402, ok: false, headers: headers({ "payment-required": "quote" }), json: async () => ({}) }); +const videoSubmit = () => ({ status: 202, ok: true, headers: headers(), json: async () => ({ id: "vid_1", status: "queued", poll_url: VIDEO_POLL }) }); +const pending = (status = "in_progress") => ({ status: 202, ok: true, headers: headers(), json: async () => ({ status, payment_status: "not_charged" }) }); +const completion402 = () => ({ status: 402, ok: false, headers: headers({ "payment-required": "completion-challenge" }), json: async () => ({ error: "Payment Required", status: "completed", id: "vid_1" }) }); +const settled = () => ({ status: 200, ok: true, headers: headers({ "x-payment-receipt": "solana-tx" }), json: async () => ({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4" }] }) }); +const fast = { pollBudgetMs: 10_000, pollIntervalMs: 1, resignIntervalMs: 60_000, submitMaySettle: false }; + +// ---- the proof itself ---- + +test("the proof verifies under node:crypto over the exact gateway message, and names the paying wallet", () => { + const signer = createPollSigner(SECRET); + assert.equal(signer.wallet, WALLET, "derived from the seed = bytes 32..63 of the keypair"); + const now = 1_791_590_400_123; + const h = signer.headers("video", "vid_1", now); + assert.equal(h[POLL_AUTH_HEADERS.timestamp], "1791590400", "unix SECONDS, floored"); + assert.equal(pollAuthMessage("video", "vid_1", 1791590400), "blockrun-poll:v1:video:vid_1:1791590400"); + assert.equal(bs58.decode(h[POLL_AUTH_HEADERS.signature]).length, 64); + assert.equal(bs58.decode(h[POLL_AUTH_HEADERS.wallet]).length, 32); + assert.ok(verifiesFor(h, "video", "vid_1")); + // Bound to kind, job and timestamp: any other message fails. + assert.equal(verifiesFor(h, "audio", "vid_1"), false); + assert.equal(verifiesFor(h, "video", "vid_2"), false); + assert.equal(verifiesFor({ ...h, [POLL_AUTH_HEADERS.timestamp]: "1791590401" }, "video", "vid_1"), false); + // A fresh timestamp is a fresh signature. + assert.notEqual(signer.headers("video", "vid_1", now + 5_000)[POLL_AUTH_HEADERS.signature], h[POLL_AUTH_HEADERS.signature]); +}); + +test("the poll route decides the kind and the job id; unknown routes get no proof", () => { + const at = (p: string) => pollRouteFor(new URL(p, "https://sol.blockrun.ai")); + assert.deepEqual(at(VIDEO_POLL), { kind: "video", jobId: "vid_1" }); + assert.deepEqual(at("/api/v1/audio/generations/aud_1"), { kind: "audio", jobId: "aud_1" }); + // An image EDIT is submitted to image2image and polled here. + assert.deepEqual(at("/api/v1/images/generations/img%3A1"), { kind: "image", jobId: "img:1" }); + assert.equal(at("/api/v1/videos/vid_1"), null); + assert.equal(at("/api/v1/videos/generations/"), null); +}); + +// ---- video: payment on completion ---- + +test("video: pending polls carry a fresh proof and never call the payment signer; completion signs exactly one payment", async () => { + const events: string[] = []; + script = [quote, videoSubmit, () => pending("queued"), () => pending(), () => { throw new TypeError("reset"); }, () => pending(), completion402, settled]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { + ...fast, + onPaidRequest: () => events.push(`armed@${requests.length}`), + onPaidResponse: () => events.push("settled"), + }); + assert.equal(r.txHash, "solana-tx"); + assert.equal(r.paidUsd, 0.5); + const polls = gets(); + assert.equal(polls.length, 6); + const proofPolls = polls.slice(0, 5); + for (const p of proofPolls) { + assert.ok(hasProof(p), "pending polls carry the proof"); + assert.equal(p.headers["PAYMENT-SIGNATURE"], undefined, "and no payment"); + assert.ok(verifiesFor(p.headers, "video", "vid_1")); + assert.equal(p.url, `https://sol.blockrun.ai${VIDEO_POLL}`, "poll_url verbatim (its query is HMAC-bound)"); + } + // Submit (1) + the completion payment (2): nothing signed while pending. + assert.equal(paymentsSigned, 2); + const paid = polls[5]; + assert.equal(paid.headers["PAYMENT-SIGNATURE"], "signed-svm-payment-2", "the payment from the completion challenge"); + assert.equal(hasProof(paid), false); + // The tracker sees exactly two payment-bearing requests: submit and the settling poll. + assert.deepEqual(events, ["armed@1", "settled", "armed@7", "settled"]); +}); + +test("video: a finished job polled again after settlement is delivered without another payment", async () => { + script = [quote, videoSubmit, () => pending(), () => ({ status: 200, ok: true, headers: headers(), json: async () => ({ status: "completed", data: [{ url: "u" }], payment: { status: "already_settled" } }) })]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal((r.data.data as Array<{ url: string }>)[0].url, "u"); + assert.equal(paymentsSigned, 1, "the submit only"); +}); + +test("video: a failed job on a proof poll is uncharged and signs nothing", async () => { + script = [quote, videoSubmit, () => ({ status: 200, ok: true, headers: headers(), json: async () => ({ status: "failed", error: "boom", payment_status: "not_charged" }) })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), (err: Error) => { + assert.ok(err instanceof JobFailedError); + assert.match(err.message, /No payment was taken/); + return true; + }); + assert.equal(paymentsSigned, 1); +}); + +test("video: a completion challenge that re-prices, re-routes or re-sponsors the job is refused unsigned", async () => { + for (const mutation of [{ amount: "900000" }, { recipient: "someone-else" }, { extra: { feePayer: "other" } }]) { + script = [quote, videoSubmit, () => { details = { ...baseDetails(), ...mutation }; return completion402(); }]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /changed the payment amount, recipient or fee payer.*No charge was made/); + assert.equal(paymentsSigned, 1, "nothing signed after the submit"); + details = baseDetails(); paymentsSigned = 0; requests = []; + } +}); + +test("video: a signing failure on the completion payment retries on the next proof poll", async () => { + failSignOnCall = 2; + script = [quote, videoSubmit, completion402, completion402, settled]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal(paymentsSigned, 2); + assert.deepEqual(gets().map(hasProof), [true, true, false]); +}); + +test("video: a stale-blockhash 402 on the settling poll uses the existing re-sign path", async () => { + const fail = Buffer.from(JSON.stringify({ success: false, errorReason: "Blockhash not found" })).toString("base64"); + script = [ + quote, videoSubmit, completion402, + () => ({ status: 402, ok: false, headers: headers({ "payment-response": fail }), json: async () => ({}) }), + () => ({ status: 402, ok: false, headers: headers({ "payment-required": "fresh" }), json: async () => ({}) }), + settled, + ]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal(paymentsSigned, 3, "submit + completion + one reactive re-sign"); +}); + +test("video: the deadline during proof polling says no charge was made — no payment was outstanding", async () => { + script = [quote, videoSubmit, ...Array.from({ length: 400 }, () => () => pending())]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { ...fast, pollBudgetMs: 40 }), (err: Error) => { + assert.match(err.message, /did not complete within/); + assert.match(err.message, /no charge was made/); + assert.doesNotMatch(err.message, /can settle server-side/); + return true; + }); + script = []; + assert.equal(paymentsSigned, 1); +}); + +// ---- music & image: paid at submit ---- + +for (const [label, endpoint, pollPath, kind] of [ + ["music", "/v1/audio/generations", "/api/v1/audio/generations/aud_1", "audio"], + ["image", "/v1/images/image2image", "/api/v1/images/generations/aud_1", "image"], +] as const) { + const submitted = () => ({ + status: 202, ok: true, headers: headers({ "x-payment-optimistic": "true" }), + json: async () => ({ id: "aud_1", status: "queued", poll_url: pollPath, payment_status: "settled_optimistic" }), + }); + + test(`${label}: polls carry the "${kind}" proof and never sign a payment`, async () => { + const events: string[] = []; + script = [quote, submitted, () => pending("queued"), () => pending(), () => ({ status: 200, ok: true, headers: headers(), json: async () => ({ status: "completed", data: [{ url: "u" }] }) })]; + const r = await solanaPaidAsyncPost(endpoint, { prompt: "t" }, { + ...fast, submitMaySettle: true, + onPaidRequest: () => events.push("armed"), onPaidResponse: () => events.push("settled"), + }); + assert.equal(r.paidUsd, 0.5); + assert.equal(paymentsSigned, 1, "the submit only"); + assert.deepEqual(events, ["armed", "settled"], "only the submit carried a payment"); + for (const p of gets()) { + assert.equal(p.headers["PAYMENT-SIGNATURE"], undefined); + assert.ok(verifiesFor(p.headers, kind, "aud_1")); + } + }); + + test(`${label}: a refused proof ("Job not found") falls back ONCE to payment-signed polling, and logs it`, async () => { + script = [ + quote, submitted, + () => ({ status: 404, ok: false, headers: headers(), json: async () => ({ error: "Job not found" }) }), + () => pending(), + () => pending(), + () => ({ status: 200, ok: true, headers: headers(), json: async () => ({ status: "completed", data: [{ url: "u" }] }) }), + ]; + const r = await solanaPaidAsyncPost(endpoint, { prompt: "t" }, { ...fast, submitMaySettle: true }); + assert.equal(r.paidUsd, 0.5); + assert.deepEqual(gets().map(hasProof), [true, false, false, false], "never back to the proof"); + assert.ok(gets().slice(1).every((g) => typeof g.headers["PAYMENT-SIGNATURE"] === "string")); + assert.equal(paymentsSigned, 2, "the submit + one fresh re-sign before the first paid poll"); + assert.equal(errLog.mock.callCount(), 1); + const line = String(errLog.mock.calls[0].arguments[0]); + assert.match(line, /refused the ownership proof.*404.*aud_1/); + assert.doesNotMatch(line, new RegExp(bs58.encode(SECRET)), "never the key"); + }); + + test(`${label}: a batch-style 402 on a proof poll takes the existing payment path`, async () => { + script = [ + quote, submitted, + () => ({ status: 402, ok: false, headers: headers(), json: async () => ({ error: "Batch payment required" }) }), + () => ({ status: 200, ok: true, headers: headers(), json: async () => ({ status: "completed", data: [{ url: "u" }] }) }), + ]; + await solanaPaidAsyncPost(endpoint, { prompt: "t" }, { ...fast, submitMaySettle: true }); + assert.deepEqual(gets().map(hasProof), [true, false]); + }); +} + +test("video: a refused proof (400 Invalid job id) falls back once; a real 404 elsewhere is not a refusal", async () => { + script = [quote, videoSubmit, () => ({ status: 400, ok: false, headers: headers(), json: async () => ({ error: "Invalid job id" }) }), settled]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.deepEqual(gets().map(hasProof), [true, false]); + assert.equal(errLog.mock.callCount(), 1); + + // The video route's refusal is 400 "Invalid job id"; a 404 there is a fault. + requests = []; + script = [quote, videoSubmit, () => ({ status: 404, ok: false, headers: headers(), json: async () => ({ error: "Job not found" }) })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /poll error 404/); +}); + +test("video: a gateway that ignores the proof (generic 402, no status) falls back to payment-signed polling", async () => { + script = [quote, videoSubmit, () => ({ status: 402, ok: false, headers: headers({ "payment-required": "x" }), json: async () => ({ error: "Payment Required" }) }), () => pending(), settled]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.deepEqual(gets().map(hasProof), [true, false, false]); +}); + +test("pollOwnership:false keeps payment-signed polling from the first poll", async () => { + script = [quote, videoSubmit, settled]; + await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { ...fast, pollOwnership: false }); + assert.equal(hasProof(gets()[0]), false); + assert.equal(gets()[0].headers["PAYMENT-SIGNATURE"], "signed-svm-payment-1"); +}); diff --git a/test/video-money-path.test.ts b/test/video-money-path.test.ts index 977c1b8..a845188 100644 --- a/test/video-money-path.test.ts +++ b/test/video-money-path.test.ts @@ -30,10 +30,12 @@ function headers(map: Record) { // call shifts one. Empty script = the test expected NO network call. let script: Array<() => unknown> = []; let fetchCalls = 0; +let fetchInits: Array<{ method?: string; headers?: Record }> = []; mock.module("../src/utils/http.js", { namedExports: { - fetchWithTimeout: async () => { + fetchWithTimeout: async (_url: string, init: { method?: string; headers?: Record } = {}) => { fetchCalls++; + fetchInits.push(init); const next = script.shift(); if (!next) throw new Error("UNEXPECTED_NETWORK_CALL"); return next(); @@ -180,6 +182,26 @@ test("the happy path books the settled amount exactly once", async () => { assert.ok(Math.abs(budget.spent - 0.4) < 1e-9, `booked once, not twice: spent=${budget.spent}`); }); +// The Solana ownership proof (utils/poll-ownership.ts) is Solana-rail only. +// Base keeps polling with the submit's EIP-3009 payment header, which lives +// for the whole job and never needed a proof. +test("Base polls keep the submit's payment header and never carry the Solana ownership proof", async () => { + fetchInits = []; + script = [resp402, respSubmit, () => respPoll({ status: "in_progress" }), () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })]; + quotedAmount = "400000"; + paymentsSigned = 0; + const { call } = makeHarness(); + const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" }); + assert.notEqual(res.isError, true, res.content?.[0]?.text); + const polls = fetchInits.filter((i) => i.method === "GET"); + assert.equal(polls.length, 2); + for (const p of polls) { + assert.equal(p.headers?.["PAYMENT-SIGNATURE"], "0xpaymentpayloadmock"); + assert.equal(Object.keys(p.headers ?? {}).some((h) => h.toLowerCase().startsWith("x-poll-")), false); + } + assert.equal(paymentsSigned, 1, "one EIP-3009 authorization for the whole job"); +}); + test("upstream failure before completion books nothing (no charge per gateway contract)", async () => { script = [resp402, respSubmit, () => respPoll({ status: "failed", error: "render exploded" })]; quotedAmount = "400000"; From 0ea642ecfd668bd8f5cc0ad3742cb1cfcc47c6b4 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:33:36 -0500 Subject: [PATCH 2/3] fix(solana): bs58 default import works under Node 20's mock loader; a signer that cannot be built is logged Node 20.19's --experimental-test-module-mocks loader resolves bs58 through its CJS build, where the default import is { default: codec }, so bs58.encode was undefined and 13 poll-ownership tests failed on node 20.19 only. The published ESM bundle was unaffected (checked on 20.19 and 22). Unwrap either shape. The createPollSigner failure was swallowed, which surfaced as 10s poll timeouts instead of the TypeError; it now goes to stderr (message only). --- src/utils/poll-ownership.ts | 10 +++++++++- src/utils/solana-402.ts | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/utils/poll-ownership.ts b/src/utils/poll-ownership.ts index b02a0db..9c26916 100644 --- a/src/utils/poll-ownership.ts +++ b/src/utils/poll-ownership.ts @@ -20,7 +20,15 @@ // Pure node:crypto + bs58, no @blockrun/llm import, so the signature can be // checked in tests against the real verifier without any module mocks. import { createPrivateKey, createPublicKey, sign as ed25519Sign, type KeyObject } from "node:crypto"; -import bs58 from "bs58"; +import bs58Module from "bs58"; + +// bs58 ships an ESM build (default export = the codec) and a CJS build +// (exports.default = the codec). The published ESM bundle resolves the ESM +// build, but Node 20's --experimental-test-module-mocks loader resolves the +// CJS one, where the default import is { default: codec } and bs58.encode is +// undefined — 13 red tests on node 20.19 only. Unwrap either shape. +const bs58: typeof bs58Module = + (bs58Module as unknown as { default?: typeof bs58Module }).default ?? bs58Module; /** The poll route's ; music polls are "audio" on the wire. */ export type PollKind = "video" | "audio" | "image"; diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index 51be0d0..4d01f1b 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -584,8 +584,12 @@ export async function solanaPaidAsyncPost( if (pollRoute) { try { pollSigner = createPollSigner(await solanaKeyToBytes(privateKey)); - } catch { + } catch (err) { pollSigner = null; + // Not fatal, but never silent: a signer that cannot be built is a bug + // (it hid a bs58 interop fault behind 10s poll timeouts). Only the + // error's message, never key material. + console.error(`[blockrun] ${what}: could not build the poll ownership proof (${err instanceof Error ? err.message : String(err)}); polling with payment signatures instead.`); } } let ownershipPolling = pollSigner !== null; From 5405392595d40813b8056b8eae7a79e634241fab Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:45:41 -0500 Subject: [PATCH 3/3] fix(solana): an unpaid completion challenge is not a result The completion 402 set lastStatus to "completed" before anything settled, and the poll classifier read that remembered status: a later 2xx with no status and no receipt (a bodiless 202) returned as the delivered video, so blockrun_video booked the quote and then failed on the missing URL. The classifier now decides on each answer's own status; lastStatus is kept for messages only. Regression test added (red before, green after). Found by codex review. --- src/utils/solana-402.ts | 11 ++++++++--- test/solana-poll-ownership.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index 4d01f1b..24271e4 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -813,7 +813,12 @@ export async function solanaPaidAsyncPost( * the payment-signed polls cannot drift on what "done" and "failed" mean. */ function classifyPollAnswer(pollResp: Response, pollData: Record): SolanaPaidPostResult | undefined { - if (typeof pollData.status === "string") lastStatus = pollData.status; + // Decide on THIS answer's status, never the remembered one. `lastStatus` + // is for messages: the completion 402 sets it to "completed" before + // anything has settled, and a later bodiless 2xx must keep polling, not + // read as the delivered (and billable) result. + const status = typeof pollData.status === "string" ? pollData.status : undefined; + if (status) lastStatus = status; // A receipt header IS settlement — the USDC moved the moment the gateway // wrote it, whatever the body looks like. Return so the caller books it @@ -823,7 +828,7 @@ export async function solanaPaidAsyncPost( return { data: pollData, paidUsd: original.paidUsd, txHash: receipt, jobId }; } - if (lastStatus === "failed") { + if (status === "failed") { // Typed on both models. The gateway echoes the upstream failure text // verbatim, and MiniMax's is "The operation was aborted due to timeout"; // a tool that classified this by prose booked a render the gateway had @@ -834,7 +839,7 @@ export async function solanaPaidAsyncPost( } throw new JobFailedError(`${failed} No payment was taken.`, { jobId }); } - if (pollResp.ok && lastStatus === "completed") { + if (pollResp.ok && status === "completed") { return { data: pollData, paidUsd: original.paidUsd, txHash: undefined, jobId }; } if (pollResp.status === 202 || pollResp.status === 504 || pollResp.ok) return undefined; diff --git a/test/solana-poll-ownership.test.ts b/test/solana-poll-ownership.test.ts index 9c0136d..fcb8bf4 100644 --- a/test/solana-poll-ownership.test.ts +++ b/test/solana-poll-ownership.test.ts @@ -204,6 +204,19 @@ test("video: a signing failure on the completion payment retries on the next pro assert.deepEqual(gets().map(hasProof), [true, true, false]); }); +test("video: an unpaid completion challenge is not a result — a later 2xx with no status and no receipt keeps polling", async () => { + // The 402 said "completed", but nothing has settled. A bodiless 202 on the + // settling poll must not be read as the delivered video (it would book the + // quote and fail on the missing URL); only the receipt or the response's + // OWN status can end the job. + const bodiless = () => ({ status: 202, ok: true, headers: headers(), json: async () => { throw new SyntaxError("Unexpected end of JSON input"); } }); + script = [quote, videoSubmit, completion402, bodiless, settled]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal((r.data.data as Array<{ url: string }>)[0].url, "https://blockrun.ai/media/vid_1.mp4"); + assert.equal(paymentsSigned, 2, "submit + the one completion payment"); +}); + test("video: a stale-blockhash 402 on the settling poll uses the existing re-sign path", async () => { const fail = Buffer.from(JSON.stringify({ success: false, errorReason: "Blockhash not found" })).toString("base64"); script = [