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..9c26916 --- /dev/null +++ b/src/utils/poll-ownership.ts @@ -0,0 +1,121 @@ +// 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 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"; + +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..24271e4 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,37 @@ 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 (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; + // 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 +626,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,7 +788,37 @@ export async function solanaPaidAsyncPost( } const pollData = await pollResp.json().catch(() => ({})) as Record; - if (typeof pollData.status === "string") lastStatus = pollData.status; + 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 { + // 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 @@ -672,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 @@ -683,26 +839,12 @@ 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) 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..fcb8bf4 --- /dev/null +++ b/test/solana-poll-ownership.test.ts @@ -0,0 +1,327 @@ +// 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: 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 = [ + 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";