diff --git a/CHANGELOG.md b/CHANGELOG.md index 55961b4..3bd4245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,27 @@ All notable changes to BlockRun MCP will be documented in this file. -## Unreleased +## 0.42.0 **`blockrun_video` now pays on Solana as well as Base.** The Solana route uses -the gateway's payment-on-completion async flow: the job POST is never retried, +the gateway's payment-on-completion async flow: the job POST is issued once, idempotent poll GETs tolerate transient disconnects, and their SVM transaction -is refreshed before its recent blockhash expires. A reactive re-challenge is -pinned to the original amount and recipient, so a slow Seedance 2.5 render can -finish inside a 15-minute polling budget without silently repricing or sending -a payment signature to another origin. Image-to-video also rejects the -Solana-incompatible `image_url` + `aspect_ratio` combination before payment. +is re-signed with a fresh blockhash every 20s so a slow Seedance 2.5 render can +settle at the end of a true 15-minute total budget (quote + submit + polling, +every request clamped to what is left). A gateway re-challenge is pinned to +the original amount, recipient and fee payer, and a payment signature is never +sent to a poll URL off the gateway's origin. A settle-failure 402 is read from +its `PAYMENT-RESPONSE` reason: a funding problem surfaces as one, while the +gateway's documented stale-blockhash re-sign path is bounded and, if +exhausted, reports the still-claimable job id instead of "out of funds". A +poll answer carrying a settlement receipt is booked before its body is +validated, so a truncated payload cannot erase a real charge from the ledger. + +**`BLOCKRUN_KEYCHAIN=strict` broke Solana image payments.** `blockrun_image` +on Solana (and the new video route) loaded the wallet through the SDK's +file-only loader, which cannot see a key that strict mode has moved into the +OS keychain, so a funded wallet was reported as missing. Both now resolve the +key the same way `blockrun_wallet` does. ## 0.41.1 diff --git a/package-lock.json b/package-lock.json index cb888fd..72c79c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.41.1", + "version": "0.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.41.1", + "version": "0.42.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/package.json b/package.json index 9d0f5c8..ed01f70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.41.1", + "version": "0.42.0", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.", "type": "module", diff --git a/skills/gentech-blockrun/SKILL.md b/skills/gentech-blockrun/SKILL.md index 667b3e0..6dc239c 100644 --- a/skills/gentech-blockrun/SKILL.md +++ b/skills/gentech-blockrun/SKILL.md @@ -292,7 +292,7 @@ blockrun_wallet(action="chain", chain="base") blockrun_wallet(action="chain") ``` -**Note:** Base is required for music, speech, video, and realface. Solana works for price, wallet, dex, rpc, surf, etc. +**Note:** Base is required for music, speech, and realface. Image and video pay on either chain. Solana works for price, wallet, dex, rpc, surf, etc. --- diff --git a/src/tools/video.ts b/src/tools/video.ts index b0a1bfa..adf69b3 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -41,6 +41,13 @@ export const VIDEO_POLL_TIMEOUT_MS = 90_000; // margin above is asserted against the value the request actually sends, // 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 +// 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 +// after a submit of unbounded length. +export const SOLANA_VIDEO_TOTAL_BUDGET_MS = 900_000; // Video pricing mirrors the gateway's calculateVideoPrice() + addTransactionFee() // (blockrun/src/lib/models.ts). Two regimes: @@ -435,27 +442,19 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC // Loaded only on the Solana branch so Base-only test harnesses and // installations never need to initialize SVM payment dependencies. const { solanaPaidAsyncPost } = await import("../utils/solana-402.js"); - // The Solana gateway derives image-to-video geometry from the seed - // image. Supplying aspect_ratio as well is rejected before quoting; - // fail locally so a caller gets the actionable form of that error. - if (image_url && aspect_ratio) { - return { - content: [{ type: "text", text: formatError("Solana image-to-video derives its frame shape from image_url; do not also pass aspect_ratio.") }], - isError: true, - }; - } const { data, paidUsd, txHash } = await solanaPaidAsyncPost( "/v1/videos/generations", body, - 300_000, { - pollBudgetMs: 900_000, + pollBudgetMs: SOLANA_VIDEO_TOTAL_BUDGET_MS, onQuote: (quotedUsd) => { if (quotedUsd === null || quotedUsd <= estimatedCost) return; gate?.release(); gate = reserveBudget(budget, agent_id, quotedUsd); - if (!gate.allowed) throw new Error(`${gate.reason}. No payment was signed.`); + // Phrased so formatError's uncharged guard suppresses its + // "fund your wallet" footer — the remedy is the budget, not USDC. + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); }, }, ); diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts index 1a25c54..9b2c648 100644 --- a/src/tools/wallet.ts +++ b/src/tools/wallet.ts @@ -26,9 +26,9 @@ To pay on Solana (no env vars, no file editing, no restart): 1. action:"chain" chain:"solana" → provisions + activates the Solana wallet 2. action:"setup" → Solana address + funding QR (send USDC SPL on Solana) Switch back with action:"chain" chain:"base". Base-only — these ignore Solana and -need Base: blockrun_music, blockrun_speech, blockrun_video, blockrun_realface, +need Base: blockrun_music, blockrun_speech, blockrun_realface, paid blockrun_price, blockrun_chat routing:"smart", and native Anthropic -(claude-*). blockrun_image pays on either chain. +(claude-*). blockrun_image and blockrun_video pay on either chain. Actions: - status (default): Both wallet addresses + USDC balances, active chain, session spending @@ -166,8 +166,8 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, Base: ${both.base.address} Solana: ${both.solana.address} -All blockrun_* calls now pay on ${active}. Note: price, video, music, speech -and RealFace are Base-only — switch back with chain:"base" for those.`; +All blockrun_* calls now pay on ${active}. Note: price, music, speech and +RealFace are Base-only — switch back with chain:"base" for those.`; return { content: [{ type: "text", text }], structuredContent: { diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index 8d7bd16..e0c1f63 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -1,10 +1,11 @@ // src/utils/solana-402.ts // Manual x402 payment flow against the Solana gateway (sol.blockrun.ai) for -// paid endpoints the SolanaLLMClient doesn't expose as public methods yet -// (image generation today; music/speech/video are candidates once their -// Solana routes ship). Mirrors the music.ts manual-402 pattern on Base, but -// signs an SPL transfer via createSolanaPaymentPayload instead of an -// EIP-3009 authorization. +// paid endpoints the SolanaLLMClient doesn't expose as public methods yet: +// image generation (solanaPaidPost, synchronous optimistic settle) and video +// (solanaPaidAsyncPost, payment-on-completion polling). Music and speech are +// candidates once their Solana routes ship. Mirrors the music.ts manual-402 +// pattern on Base, but signs an SPL transfer via createSolanaPaymentPayload +// instead of an EIP-3009 authorization. import { SolanaLLMClient, PaymentError, @@ -13,30 +14,100 @@ import { createSolanaPaymentPayload, solanaKeyToBytes, solanaPublicKey, - loadSolanaWallet, SOLANA_NETWORK, } from "@blockrun/llm"; import { fetchWithTimeout } from "./http.js"; +import { pollTimeoutFor } from "./poll.js"; +import { resolveSolanaKey } from "./wallet.js"; import { amountToUsd } from "./budget.js"; const QUOTE_TIMEOUT_MS = 15_000; +// Timing for the payment-on-completion loop (solanaPaidAsyncPost). Unlike the +// Base video loop there is no EIP-3009 validBefore to stay inside: the signed +// SPL transaction carries a recent blockhash that the cluster honours for +// ~60s of block time, and the gateway settles on the poll that observes +// "completed" — with the signature it RECEIVES on that poll. What has to stay +// fresh is therefore the signature's age at send time, which is at most +// +// RESIGN_INTERVAL + POLL_INTERVAL (sleep) + one RPC round-trip +// +// The SDK fetches the blockhash at `finalized` commitment (already ~13s old) +// and caches it for 10s, so a 45s interval routinely presented a 70s+ +// signature and turned the bounded reactive path into the normal path. 20s +// keeps the worst case near 40s. The poll GET itself is capped at the Solana +// poll route's own maxDuration (60s) rather than Base's 90s: a stalled poll +// is the one place the signature ages without a re-sign, and the gateway +// can't answer past 60s anyway. Submit gets Base's 30s — the gateway verifies +// and enqueues in 3-20s, and a 300s hold here was silently adding five +// minutes to the "15 min hard cap" the tool description promises. +export const SOLANA_ASYNC_DEFAULT_BUDGET_MS = 900_000; +export const SOLANA_ASYNC_POLL_INTERVAL_MS = 5_000; +export const SOLANA_ASYNC_SUBMIT_TIMEOUT_MS = 30_000; +export const SOLANA_ASYNC_POLL_TIMEOUT_MS = 60_000; +export const SOLANA_ASYNC_RESIGN_INTERVAL_MS = 20_000; +// A failed proactive re-sign (RPC blip) waits this long before trying again +// instead of re-deriving the key and hitting the RPC on every 5s iteration. +export const SOLANA_ASYNC_RESIGN_RETRY_MS = 10_000; +export const SOLANA_ASYNC_MAX_REACTIVE_RESIGNS = 3; + +// Settle-failure reasons the Solana gateway itself treats as permanent for the +// presented authorization (mirrors PERMANENT_ERRORS in the gateway's +// x402-solana.ts). Anything else on a poll 402 — stale blockhash, a concurrent +// settle claim, a facilitator hiccup — is the gateway's documented "re-sign +// and re-poll" path and must not be reported as a funding problem. +const PERMANENT_SETTLE_PATTERNS = [ + "insufficient", + "invalid signature", + "invalid payment", + "unauthorized", + "forbidden", + "invalid_payload", + "expired", +]; +function isPermanentSettleFailure(reason: string | undefined): boolean { + if (!reason) return false; + const lower = reason.toLowerCase(); + return PERMANENT_SETTLE_PATTERNS.some((p) => lower.includes(p)); +} + +/** errorReason from a base64 x402 PAYMENT-RESPONSE header, when present and well-formed. */ +function settleFailureReason(response: Response): string | undefined { + const raw = response.headers.get("payment-response") || response.headers.get("PAYMENT-RESPONSE"); + if (!raw) return undefined; + try { + const parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8")) as { errorReason?: unknown }; + return typeof parsed.errorReason === "string" && parsed.errorReason ? parsed.errorReason : undefined; + } catch { + return undefined; + } +} + export interface SolanaPaidPostResult { data: Record; /** Actual USD charged, from the 402 quote. Null when unparseable — callers fall back to their estimate. */ paidUsd: number | null; /** Settlement receipt from the terminal response, when the gateway returns one. */ txHash?: string; + /** Gateway job id for async flows — the handle a user needs to reclaim a finished clip. */ + jobId?: string; } export interface SolanaPaidAsyncPostOptions { - /** Total time allowed for submit + polling. Defaults to 15 minutes. */ + /** + * Total wall time allowed for quote + submit + polling, measured from the + * call's entry. Defaults to SOLANA_ASYNC_DEFAULT_BUDGET_MS (15 minutes). + */ pollBudgetMs?: number; - /** Delay between idempotent poll GETs. Defaults to 5 seconds. */ + /** Delay between idempotent poll GETs. Defaults to SOLANA_ASYNC_POLL_INTERVAL_MS. */ pollIntervalMs?: number; - /** Refresh the SVM transaction before its recent blockhash goes stale. */ + /** Timeout for the single paid submit POST. Defaults to SOLANA_ASYNC_SUBMIT_TIMEOUT_MS. */ + submitTimeoutMs?: number; + /** Timeout for each poll GET (always clamped to the remaining budget). Defaults to SOLANA_ASYNC_POLL_TIMEOUT_MS. */ + pollTimeoutMs?: number; + /** Re-sign the SVM transaction (fresh blockhash) this often. Defaults to SOLANA_ASYNC_RESIGN_INTERVAL_MS. */ resignIntervalMs?: number; - /** Maximum reactive re-signs after a completed poll rejects a stale signature. */ + /** Maximum reactive re-signs after a completed poll rejects a stale signature. Defaults to SOLANA_ASYNC_MAX_REACTIVE_RESIGNS. */ maxReactiveResigns?: number; /** Called after the authoritative quote is parsed and before anything is signed. */ onQuote?: (quotedUsd: number | null) => void; @@ -79,6 +150,8 @@ async function signSolanaChallenge( const apiUrl = SolanaLLMClient.SOLANA_API_URL; const { paymentRequired, details } = context; const feePayer = (details.extra as { feePayer: string }).feePayer; + // Only sign for a resource on the gateway's own origin — a spoofed quote must + // not relabel the payment as authorizing some other resource. const quotedResource = details.resource?.url; const resourceUrl = quotedResource && quotedResource.startsWith(apiUrl) ? quotedResource : url; const fromAddress = await solanaPublicKey(privateKey); @@ -120,7 +193,10 @@ export async function solanaPaidPost( onQuote?: (quotedUsd: number | null) => void; }, ): Promise { - const privateKey = process.env.SOLANA_WALLET_KEY || loadSolanaWallet(); + // resolveSolanaKey, not the SDK's file-only loader: under + // BLOCKRUN_KEYCHAIN=strict the .solana-session file is retired once its key + // is in the OS keychain, and getChain() still reports "solana" for it. + const privateKey = resolveSolanaKey(); if (!privateKey) { throw new PaymentError('No Solana wallet found. Run blockrun_wallet with action:"setup" to provision one.'); } @@ -176,16 +252,37 @@ export async function solanaPaidPost( /** * Solana x402 flow for payment-on-completion media endpoints such as video. * The paid POST is issued exactly once. Only idempotent poll GETs are retried; - * their SVM transaction is periodically refreshed because a recent blockhash - * expires long before a slow Seedance 2.5 job can finish. + * their SVM transaction is periodically re-signed with a fresh blockhash + * because one expires long before a slow Seedance 2.5 job can finish. The + * authorized amount, recipient and fee payer stay pinned to the original 402. + * + * Money-path invariants (each has a test in test/solana-402-async.test.ts): + * - nothing is signed before onQuote has approved the authoritative price; + * - the budget clock starts here, not after submit, so the caller's cap is a + * true total; every request is clamped to what is left of it; + * - a poll answer carrying X-Payment-Receipt IS settlement, whatever its body; + * - a poll 402 is classified from PAYMENT-RESPONSE: permanent failures + * surface as a PaymentError (funding), everything else is the gateway's + * documented re-sign path, bounded by maxReactiveResigns. */ export async function solanaPaidAsyncPost( endpoint: string, body: Record, - requestTimeoutMs: number, opts: SolanaPaidAsyncPostOptions = {}, ): Promise { - const privateKey = process.env.SOLANA_WALLET_KEY || loadSolanaWallet(); + const startedAt = Date.now(); + const pollBudgetMs = opts.pollBudgetMs ?? SOLANA_ASYNC_DEFAULT_BUDGET_MS; + const pollIntervalMs = opts.pollIntervalMs ?? SOLANA_ASYNC_POLL_INTERVAL_MS; + const submitTimeoutMs = opts.submitTimeoutMs ?? SOLANA_ASYNC_SUBMIT_TIMEOUT_MS; + const pollTimeoutMs = opts.pollTimeoutMs ?? SOLANA_ASYNC_POLL_TIMEOUT_MS; + const resignIntervalMs = opts.resignIntervalMs ?? SOLANA_ASYNC_RESIGN_INTERVAL_MS; + const maxReactiveResigns = opts.maxReactiveResigns ?? SOLANA_ASYNC_MAX_REACTIVE_RESIGNS; + const deadline = startedAt + pollBudgetMs; + + // resolveSolanaKey, not the SDK's file-only loader: under + // BLOCKRUN_KEYCHAIN=strict the .solana-session file is retired once its key + // is in the OS keychain, and getChain() still reports "solana" for it. + const privateKey = resolveSolanaKey(); if (!privateKey) { throw new PaymentError('No Solana wallet found. Run blockrun_wallet with action:"setup" to provision one.'); } @@ -196,115 +293,169 @@ export async function solanaPaidAsyncPost( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - }, QUOTE_TIMEOUT_MS); + }, pollTimeoutFor(deadline, Date.now(), QUOTE_TIMEOUT_MS) || QUOTE_TIMEOUT_MS); if (quoteResp.status !== 402) { + // Same as solanaPaidPost and the Base video path: a paid route that does + // not quote is a fault, not a free render. Returning the body as a + // completed clip with paidUsd 0 made recordActualSpend book the full + // ESTIMATE (0 is "unknown" there) for a call that charged nothing. const data = await quoteResp.json().catch(() => ({})) as Record; - if (quoteResp.ok) return { data, paidUsd: 0 }; throw new Error(`Unexpected status ${quoteResp.status} (the endpoint did not return a quote): ${JSON.stringify(data)}`); } const paymentHeader = await readPaymentRequired(quoteResp); const original = parseSolanaChallenge(paymentHeader); if (original.paidUsd === null) { - throw new PaymentError(`The gateway's Solana quote carried an unreadable amount (${JSON.stringify(original.details.amount)}); refusing to sign it.`); + throw new PaymentError(`The gateway's Solana quote carried an unreadable amount (${JSON.stringify(original.details.amount)}); refusing to sign it. No charge was made.`); } opts.onQuote?.(original.paidUsd); + + // Stamp BEFORE signing: the blockhash is fetched inside the sign call, and a + // slow submit afterwards must not make the tracked age lag the real one. + let nextResignAt = Date.now() + resignIntervalMs; let paymentPayload = await signSolanaChallenge(original, url, privateKey); + const submitTimeout = pollTimeoutFor(deadline, Date.now(), submitTimeoutMs); + if (submitTimeout === 0) { + throw new Error(`Budget of ${Math.round(pollBudgetMs / 1000)}s was spent before the job could be submitted. No charge was made.`); + } const submitResp = await fetchWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentPayload }, body: JSON.stringify(body), - }, requestTimeoutMs); + }, submitTimeout); if (submitResp.status === 402) throw new PaymentError("Payment was rejected. Check your Solana USDC balance."); const submitData = await submitResp.json().catch(() => ({})) as Record; - if (submitResp.status === 200) { + const pollPath = typeof submitData.poll_url === "string" ? submitData.poll_url : ""; + const jobId = typeof submitData.id === "string" ? submitData.id : undefined; + // A 200 is terminal only when it is NOT an async job envelope. The Base + // path accepts 200 as a submit status and polls; a 200 + poll_url treated + // as a finished clip would book the charge and then fail on the missing URL. + if (submitResp.status === 200 && !pollPath) { return { data: submitData, paidUsd: original.paidUsd, txHash: submitResp.headers.get("x-payment-receipt") || undefined, + jobId, }; } - if (submitResp.status !== 202) { + if (submitResp.status !== 202 && submitResp.status !== 200) { throw new Error(`API error ${submitResp.status}: ${JSON.stringify(submitData)}`); } - const pollPath = typeof submitData.poll_url === "string" ? submitData.poll_url : ""; - if (!pollPath) throw new Error(`Submit response missing id/poll_url: ${JSON.stringify(submitData)}`); + if (!pollPath) throw new Error(`Submit response missing poll_url: ${JSON.stringify(submitData)}`); const pollUrl = new URL(pollPath, apiUrl); if (pollUrl.origin !== new URL(apiUrl).origin) { - throw new PaymentError(`Refusing to send a payment signature to an off-gateway poll URL: ${pollUrl.origin}`); + throw new PaymentError(`Refusing to send a payment signature to an off-gateway poll URL: ${pollUrl.origin}. No charge was made.`); } - const pollBudgetMs = opts.pollBudgetMs ?? 900_000; - const pollIntervalMs = opts.pollIntervalMs ?? 5_000; - const resignIntervalMs = opts.resignIntervalMs ?? 45_000; - let resignsLeft = opts.maxReactiveResigns ?? 3; - const deadline = Date.now() + pollBudgetMs; + // The gateway keeps a finished job claimable for ~48h; every message that + // gives up on one must say so, because re-running the tool submits (and + // pays for) a brand-new job. + const reclaimNote = `The finished job stays claimable on the gateway for ~48h${jobId ? ` (job ${jobId})` : ""}; re-running blockrun_video would start and charge a new job.`; + + let resignsLeft = maxReactiveResigns; let lastStatus = typeof submitData.status === "string" ? submitData.status : "queued"; - let lastSignedAt = Date.now(); + let lastSettleReason: string | undefined; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - const remaining = deadline - Date.now(); - if (remaining <= 0) break; // Proactively refresh only the transaction/blockhash. The authorized - // amount and recipient remain pinned to the original 402 challenge. - if (Date.now() - lastSignedAt >= resignIntervalMs) { + // amount, recipient and fee payer remain pinned to the original 402. + if (Date.now() >= nextResignAt) { + const attemptedAt = Date.now(); try { paymentPayload = await signSolanaChallenge(original, url, privateKey); - lastSignedAt = Date.now(); + nextResignAt = attemptedAt + resignIntervalMs; } catch { - // Best effort: keep polling with the previous signature. A terminal + // Best effort: keep polling with the previous signature, retry the + // refresh after a short back-off rather than on every iteration. A // 402 below obtains a fresh challenge and reports a precise failure. + nextResignAt = Date.now() + SOLANA_ASYNC_RESIGN_RETRY_MS; } } + // Clamp to the budget that is actually left: checking the deadline only + // at the top of the loop bounds when a poll may START, not when it ends. + const pollTimeout = pollTimeoutFor(deadline, Date.now(), pollTimeoutMs); + if (pollTimeout === 0) break; + let pollResp: Response; try { pollResp = await fetchWithTimeout(pollUrl.toString(), { method: "GET", headers: { "PAYMENT-SIGNATURE": paymentPayload }, - }, Math.min(requestTimeoutMs, remaining)); + }, pollTimeout); } catch { // Polling is idempotent and settlement has not been observed. A transient // disconnect is safe to retry inside the existing deadline. continue; } - if (pollResp.status === 402 && resignsLeft > 0) { + if (pollResp.status === 402) { + // The gateway's settle-failure 402 carries PAYMENT-RESPONSE (the reason), + // not a fresh challenge; the challenge comes from a separate unpaid GET. + // Its body is informational only — consume it to release the socket. + lastSettleReason = settleFailureReason(pollResp) ?? lastSettleReason; + await pollResp.json().catch(() => ({})); + if (isPermanentSettleFailure(lastSettleReason)) { + throw new PaymentError(`Payment was rejected while settling the completed Solana video (${lastSettleReason}). Check your Solana USDC balance. ${reclaimNote}`); + } + if (resignsLeft <= 0) { + throw new Error(`Solana settlement did not go through after ${maxReactiveResigns} re-signs${lastSettleReason ? ` (last gateway reason: ${lastSettleReason})` : ""}. The video finished upstream but this client observed no settlement receipt, so no charge was made. ${reclaimNote}`); + } resignsLeft--; - const challenge = await fetchWithTimeout(pollUrl.toString(), { method: "GET" }, Math.min(requestTimeoutMs, remaining)); - if (challenge.status === 402) { - const freshHeader = await readPaymentRequired(challenge); - const fresh = parseSolanaChallenge(freshHeader); - if (fresh.details.amount !== original.details.amount || fresh.details.recipient !== original.details.recipient) { - throw new PaymentError("The refreshed poll challenge changed the payment amount or recipient; refusing to re-authorize it."); - } - paymentPayload = await signSolanaChallenge(fresh, pollUrl.toString(), privateKey); - lastSignedAt = Date.now(); + let challenge: Response; + try { + challenge = await fetchWithTimeout(pollUrl.toString(), { method: "GET" }, pollTimeoutFor(deadline, Date.now(), pollTimeoutMs) || 1); + } catch { + // Same idempotent-retry rule as the paid poll above: a transient + // disconnect on the challenge fetch must not abandon a job the + // gateway has already finished. + continue; + } + if (challenge.status !== 402) { + await challenge.json().catch(() => ({})); continue; } + const freshHeader = await readPaymentRequired(challenge); + const fresh = parseSolanaChallenge(freshHeader); + 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 new PaymentError("The refreshed poll challenge changed the payment amount, recipient or fee payer; refusing to re-authorize it. No charge was made."); + } + nextResignAt = Date.now() + resignIntervalMs; + paymentPayload = await signSolanaChallenge(fresh, pollUrl.toString(), privateKey); + continue; } - if (pollResp.status === 402) throw new PaymentError("Payment was rejected while settling the completed Solana video."); const pollData = await pollResp.json().catch(() => ({})) as Record; if (typeof pollData.status === "string") lastStatus = pollData.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 + // before validating the payload (a truncated body must not erase a charge). + const receipt = pollResp.headers.get("x-payment-receipt") || pollResp.headers.get("X-Payment-Receipt"); + if (pollResp.ok && receipt) { + return { data: pollData, paidUsd: original.paidUsd, txHash: receipt, jobId }; + } + if (lastStatus === "failed") { throw new Error(`Video generation failed upstream: ${String(pollData.error || "unknown")}. No payment was taken.`); } - if (lastStatus === "completed") { - return { - data: pollData, - paidUsd: original.paidUsd, - txHash: pollResp.headers.get("x-payment-receipt") || undefined, - }; + if (pollResp.ok && lastStatus === "completed") { + return { data: pollData, paidUsd: original.paidUsd, txHash: undefined, jobId }; } if (pollResp.status === 202 || pollResp.status === 504 || pollResp.ok) continue; throw new Error(`Video poll error ${pollResp.status}: ${JSON.stringify(pollData)}`); } - throw new Error(`Video generation did not complete within ${Math.round(pollBudgetMs / 1000)}s (last status: ${lastStatus}). Settlement only happens on completion, so no payment was taken.`); + throw new Error(`Video generation did not complete within ${Math.round(pollBudgetMs / 1000)}s (last status: ${lastStatus}). No settlement receipt was observed by this client; a poll still in flight at the deadline can settle server-side, so check the wallet's recent transactions before retrying. ${reclaimNote}`); } diff --git a/test/image-cost.test.ts b/test/image-cost.test.ts index 9004b61..8d881aa 100644 --- a/test/image-cost.test.ts +++ b/test/image-cost.test.ts @@ -19,6 +19,9 @@ mock.module("../src/utils/wallet.js", { getImageClient: () => fakeImageClient, getOrCreateWalletKey: () => "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", getWalletInfo: async () => ({ address: "0xTEST" }), + // image.ts statically imports utils/solana-402.ts, which now resolves the + // Solana key through wallet.ts; this suite only exercises the Base path. + resolveSolanaKey: () => undefined, }, }); diff --git a/test/solana-402-async.test.ts b/test/solana-402-async.test.ts index 361db6a..165ff0a 100644 --- a/test/solana-402-async.test.ts +++ b/test/solana-402-async.test.ts @@ -1,5 +1,9 @@ // Run with: npm test (tsx --experimental-test-module-mocks --test) -import { test, mock } from "node:test"; +// +// Drives solanaPaidAsyncPost end-to-end against a scripted fetch. Every money- +// path branch has a case here: the loop spends real USDC in production and +// the Base video harness cannot reach it (its fetch mock is a sentinel). +import { test, mock, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; function headers(map: Record = {}) { @@ -7,13 +11,20 @@ function headers(map: Record = {}) { return { get: (name: string) => lower[name.toLowerCase()] ?? null }; } +type Scripted = { url: string; method: string; headers: Record }; let script: Array<() => unknown> = []; -let requests: Array<{ url: string; method: string }> = []; +let requests: Scripted[] = []; let signaturesCreated = 0; +let signOptions: Array> = []; +let timeouts: number[] = []; +// 1-based index of the createSolanaPaymentPayload call that should throw (null = none). +let failSignOnCall: number | null = null; +let signCalls = 0; mock.module("../src/utils/http.js", { namedExports: { - fetchWithTimeout: async (url: string, init: { method?: string }) => { - requests.push({ url, method: init.method || "GET" }); + fetchWithTimeout: async (url: string, init: { method?: string; headers?: Record }, timeoutMs: number) => { + requests.push({ url, method: init.method || "GET", headers: init.headers || {} }); + timeouts.push(timeoutMs); const next = script.shift(); if (!next) throw new Error("UNEXPECTED_NETWORK_CALL"); return next(); @@ -21,74 +32,276 @@ mock.module("../src/utils/http.js", { }, }); +// The helper reads the key through the keychain-aware resolver, never the +// SDK's file-only loader (strict keychain mode retires the session file). +let walletKey: string | undefined = "test-solana-key"; +mock.module("../src/utils/wallet.js", { + namedExports: { resolveSolanaKey: () => walletKey }, +}); + +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(); mock.module("@blockrun/llm", { namedExports: { SolanaLLMClient: { SOLANA_API_URL: "https://sol.blockrun.ai/api" }, PaymentError: class PaymentError extends Error {}, SOLANA_NETWORK: "solana:mainnet", - loadSolanaWallet: () => "test-solana-key", solanaPublicKey: async () => "payer", solanaKeyToBytes: async () => new Uint8Array(64), - createSolanaPaymentPayload: async () => { signaturesCreated++; return "signed-svm-payment"; }, + createSolanaPaymentPayload: async (...args: unknown[]) => { + signCalls++; + if (failSignOnCall === signCalls) throw new Error("rpc blip"); + signaturesCreated++; + signOptions.push(args[5] as Record); + return `signed-svm-payment-${signaturesCreated}`; + }, parsePaymentRequired: () => ({}), - extractPaymentDetails: () => ({ - network: "solana:mainnet", - recipient: "recipient", - amount: "500000", - extra: { feePayer: "fee-payer" }, - resource: { url: "https://sol.blockrun.ai/api/v1/videos/generations" }, - }), + extractPaymentDetails: () => details, }, }); const { solanaPaidAsyncPost } = await import("../src/utils/solana-402.js"); + +beforeEach(() => { + script = []; requests = []; signaturesCreated = 0; signOptions = []; timeouts = []; + failSignOnCall = null; signCalls = 0; walletKey = "test-solana-key"; details = baseDetails(); +}); +// Unconsumed scripted responses fail the test that owns them, not the next one. +afterEach(() => { assert.equal(script.length, 0, "unconsumed scripted responses"); }); + +const 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 submit = (pollUrl: string) => ({ status: 202, ok: true, headers: headers(), json: async () => ({ id: "vid_1", status: "queued", poll_url: pollUrl }) }); -const poll = (status: string, extra: Record = {}) => ({ status: status === "completed" ? 200 : 202, ok: true, headers: headers({ "x-payment-receipt": "solana-tx" }), json: async () => ({ status, ...extra }) }); +const submit = (pollUrl: string = POLL, status = 202) => ({ status, ok: true, headers: headers(), json: async () => ({ id: "vid_1", status: "queued", poll_url: pollUrl }) }); +const poll = (status: string, extra: Record = {}, withReceipt = status === "completed") => ({ + status: status === "completed" ? 200 : 202, + ok: true, + headers: headers(withReceipt ? { "x-payment-receipt": "solana-tx" } : {}), + json: async () => ({ status, ...extra }), +}); +const paymentResponse = (errorReason: string) => Buffer.from(JSON.stringify({ success: false, network: "solana", errorReason })).toString("base64"); +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 }; +const gets = () => requests.filter((r) => r.method === "GET"); +const posts = () => requests.filter((r) => r.method === "POST"); test("async Solana flow submits once, retries only idempotent polls, and returns the receipt", async () => { - requests = []; - signaturesCreated = 0; script = [ quote, - () => submit("/api/v1/videos/poll/vid_1"), + () => submit(), () => { throw new TypeError("transient disconnect"); }, () => poll("in_progress"), () => poll("completed", { data: [{ url: "https://blockrun.ai/media/vid_1.mp4" }] }), ]; - const result = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "test" }, 100, { - pollBudgetMs: 100, - pollIntervalMs: 1, - resignIntervalMs: 10_000, - }); + const result = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "test" }, fast); assert.equal(result.paidUsd, 0.5); assert.equal(result.txHash, "solana-tx"); + assert.equal(result.jobId, "vid_1"); assert.equal((result.data.data as Array<{ url: string }>)[0].url, "https://blockrun.ai/media/vid_1.mp4"); - assert.equal(requests.filter((request) => request.method === "POST").length, 2, "probe + one paid submit only"); - assert.equal(requests.filter((request) => request.method === "GET").length, 3); + assert.equal(posts().length, 2, "probe + one paid submit only"); + assert.equal(gets().length, 3); assert.equal(signaturesCreated, 1); + assert.equal(gets()[0].url, `https://sol.blockrun.ai${POLL}`, "poll_url resolves against the gateway origin verbatim (its sig query is HMAC-bound)"); + assert.equal(gets()[0].headers["PAYMENT-SIGNATURE"], "signed-svm-payment-1"); }); test("a gateway cannot redirect the payment-bearing poll to another origin", async () => { - requests = []; script = [quote, () => submit("https://evil.example/poll/vid_1")]; await assert.rejects( - solanaPaidAsyncPost("/v1/videos/generations", { prompt: "test" }, 100, { pollBudgetMs: 10, pollIntervalMs: 1 }), - /off-gateway poll URL/, + solanaPaidAsyncPost("/v1/videos/generations", { prompt: "test" }, fast), + /off-gateway poll URL.*No charge was made/, ); assert.equal(requests.some((request) => request.url.includes("evil.example")), false); }); test("the authoritative quote hook runs before any Solana payment is signed", async () => { - requests = []; - signaturesCreated = 0; script = [quote]; await assert.rejects( - solanaPaidAsyncPost("/v1/videos/generations", { prompt: "test" }, 100, { - onQuote: () => { throw new Error("budget exceeded"); }, - }), + solanaPaidAsyncPost("/v1/videos/generations", { prompt: "test" }, { onQuote: () => { throw new Error("budget exceeded"); } }), /budget exceeded/, ); assert.equal(signaturesCreated, 0); assert.equal(requests.length, 1); }); + +test("an off-gateway resource URL in the quote is never what gets signed", async () => { + details = { ...baseDetails(), resource: { url: "https://evil.example/v1/videos/generations" } }; + script = [quote, () => submit(), () => poll("completed", { data: [{ url: "u" }] })]; + await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(signOptions[0].resourceUrl, "https://sol.blockrun.ai/api/v1/videos/generations"); +}); + +test("a non-Solana challenge and a missing feePayer are refused before signing", async () => { + details = { ...baseDetails(), network: "eip155:8453" }; + script = [quote]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /Expected a Solana payment quote/); + details = { ...baseDetails(), extra: {} }; + script = [quote]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /Missing feePayer/); + assert.equal(signaturesCreated, 0); +}); + +test("an unreadable quote amount is never signed", async () => { + details = { ...baseDetails(), amount: "not-a-number" }; + script = [quote]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /unreadable amount/); + assert.equal(signaturesCreated, 0); +}); + +test("a missing Solana wallet is a PaymentError with the setup hint", async () => { + walletKey = undefined; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /No Solana wallet found/); + assert.equal(requests.length, 0); +}); + +test("a paid route that answers 2xx instead of a quote is a fault, not a free render", async () => { + script = [() => ({ status: 200, ok: true, headers: headers(), json: async () => ({ data: [{ url: "u" }] }) })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /Unexpected status 200/); + assert.equal(signaturesCreated, 0); +}); + +test("a rejected paid submit is a PaymentError and issues no polls", async () => { + script = [quote, quote]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /Payment was rejected/); + assert.equal(gets().length, 0); +}); + +test("a synchronous 200 submit without poll_url returns without polling", async () => { + script = [quote, () => ({ status: 200, ok: true, headers: headers({ "x-payment-receipt": "sync-tx" }), json: async () => ({ status: "completed", data: [{ url: "u" }] }) })]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "sync-tx"); + assert.equal(gets().length, 0); +}); + +test("a 200 submit that carries poll_url is an async job and gets polled", async () => { + script = [quote, () => submit(POLL, 200), () => poll("completed", { data: [{ url: "u" }] })]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal(gets().length, 1); +}); + +test("a 202 without poll_url and a non-2xx submit are refused", async () => { + script = [quote, () => ({ status: 202, ok: true, headers: headers(), json: async () => ({ id: "vid_1", status: "queued" }) })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /missing poll_url/); + script = [quote, () => ({ status: 500, ok: false, headers: headers(), json: async () => ({ error: "boom" }) })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /API error 500/); +}); + +test("the proactive re-sign refreshes the poll signature every resignIntervalMs", async () => { + script = [quote, () => submit(), () => poll("in_progress"), () => poll("in_progress"), () => poll("completed", { data: [{ url: "u" }] })]; + await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { ...fast, resignIntervalMs: 0 }); + assert.equal(signaturesCreated, 4, "submit + one re-sign before each of the 3 polls"); + assert.deepEqual(gets().map((g) => g.headers["PAYMENT-SIGNATURE"]), ["signed-svm-payment-2", "signed-svm-payment-3", "signed-svm-payment-4"]); + assert.equal(signOptions[3].resourceUrl, "https://sol.blockrun.ai/api/v1/videos/generations", "proactive re-sign keeps the original resource"); +}); + +test("a failed proactive re-sign keeps polling with the previous signature and backs off", async () => { + script = [quote, () => submit(), () => poll("in_progress"), () => poll("in_progress"), () => poll("completed", { data: [{ url: "u" }] })]; + failSignOnCall = 2; // the first proactive refresh, not the submit signature + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { ...fast, resignIntervalMs: 0 }); + assert.equal(r.txHash, "solana-tx"); + // The blip cost exactly one refresh; the 10s retry back-off means the two + // following polls reuse the submit signature instead of re-hitting the RPC. + assert.equal(signaturesCreated, 1); + assert.deepEqual(gets().map((g) => g.headers["PAYMENT-SIGNATURE"]), ["signed-svm-payment-1", "signed-svm-payment-1", "signed-svm-payment-1"]); +}); + +test("a stale-blockhash settle 402 re-signs against an identical unpaid challenge and completes", async () => { + script = [quote, () => submit(), settleFail402, challenge402, () => poll("completed", { data: [{ url: "u" }] })]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal(signaturesCreated, 2); + const [paid, challenge, retry] = gets(); + assert.equal(paid.headers["PAYMENT-SIGNATURE"], "signed-svm-payment-1"); + assert.equal(challenge.headers["PAYMENT-SIGNATURE"], undefined, "the challenge GET is unpaid"); + assert.equal(retry.headers["PAYMENT-SIGNATURE"], "signed-svm-payment-2"); + assert.equal(signOptions[1].resourceUrl, "https://sol.blockrun.ai/api/v1/videos/generations"); +}); + +test("a refreshed challenge that re-prices, re-routes or re-sponsors the job is refused", async () => { + for (const mutation of [{ amount: "900000" }, { recipient: "someone-else" }, { extra: { feePayer: "other-fee-payer" } }]) { + script = [quote, () => submit(), settleFail402, () => { details = { ...baseDetails(), ...mutation }; return challenge402(); }]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /changed the payment amount, recipient or fee payer.*No charge was made/); + assert.equal(signaturesCreated, 1, "nothing signed after the mutated challenge"); + details = baseDetails(); signaturesCreated = 0; requests = []; + } +}); + +test("a transient failure on the challenge GET does not abandon the job", async () => { + script = [quote, () => submit(), settleFail402, () => { throw new TypeError("reset"); }, settleFail402, challenge402, () => poll("completed", { data: [{ url: "u" }] })]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal(signaturesCreated, 2); +}); + +test("reactive re-signs are bounded and exhaustion names the claimable job, not a funding problem", async () => { + script = [quote, () => submit(), settleFail402, challenge402, settleFail402]; + await assert.rejects( + solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { ...fast, maxReactiveResigns: 1 }), + (err: Error) => { + assert.match(err.message, /did not go through after 1 re-signs/); + assert.match(err.message, /Blockhash not found/); + assert.match(err.message, /claimable.*job vid_1/); + assert.match(err.message, /no charge was made/); + assert.doesNotMatch(err.message, /rejected|balance|insufficient/i, "must not route to the top-up flow"); + return true; + }, + ); + assert.equal(signaturesCreated, 2); +}); + +test("a permanent settle reason is a PaymentError routed to funding", async () => { + script = [quote, () => submit(), () => settleFail402("insufficient funds for transfer")]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /Payment was rejected.*insufficient funds.*claimable/); + assert.equal(signaturesCreated, 1, "no re-sign against a wallet that cannot pay"); +}); + +test("a poll answer carrying a receipt is settlement even when its body is malformed", async () => { + script = [quote, () => submit(), () => ({ status: 200, ok: true, headers: headers({ "x-payment-receipt": "solana-tx" }), json: async () => { throw new SyntaxError("truncated"); } })]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, "solana-tx"); + assert.equal(r.paidUsd, 0.5); + assert.deepEqual(r.data, {}); +}); + +test("a completed body without a receipt is still returned, with no txHash", async () => { + script = [quote, () => submit(), () => poll("completed", { data: [{ url: "u" }] }, false)]; + const r = await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast); + assert.equal(r.txHash, undefined); + assert.equal((r.data.data as Array<{ url: string }>)[0].url, "u"); +}); + +test("a completed status on a non-2xx poll is not trusted", async () => { + script = [quote, () => submit(), () => ({ status: 500, ok: false, headers: headers(), json: async () => ({ status: "completed", data: [{ url: "u" }] }) })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /Video poll error 500/); +}); + +test("a failed job rejects without a receipt and a 504 is retried", async () => { + script = [quote, () => submit(), () => ({ status: 504, ok: false, headers: headers(), json: async () => ({}) }), () => poll("failed", { error: "render exploded" })]; + await assert.rejects(solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, fast), /render exploded.*No payment was taken/); + assert.equal(gets().length, 2); +}); + +test("deadline expiry reports the last status and the claimable job", async () => { + script = [quote, () => submit(), ...Array.from({ length: 200 }, () => () => poll("in_progress"))]; + await assert.rejects( + solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { pollBudgetMs: 30, pollIntervalMs: 1, resignIntervalMs: 60_000 }), + /did not complete within 0s \(last status: in_progress\).*claimable.*job vid_1/, + ); + script = []; +}); + +test("every request is clamped to the remaining budget (no request may outlive the deadline)", async () => { + script = [quote, () => submit(), () => poll("in_progress"), () => poll("completed", { data: [{ url: "u" }] })]; + await solanaPaidAsyncPost("/v1/videos/generations", { prompt: "t" }, { pollBudgetMs: 5_000, pollIntervalMs: 1, resignIntervalMs: 60_000, pollTimeoutMs: 60_000, submitTimeoutMs: 30_000 }); + assert.equal(timeouts.length, 4); + for (const t of timeouts) assert.ok(t > 0 && t <= 5_000, `timeout ${t} exceeds the 5s budget`); + assert.ok(timeouts[1] <= 5_000 && timeouts[2] <= 5_000, "submit and poll were both clamped below their 30s/60s defaults"); +}); diff --git a/test/video-solana.test.ts b/test/video-solana.test.ts index bea9709..9b9af55 100644 --- a/test/video-solana.test.ts +++ b/test/video-solana.test.ts @@ -1,11 +1,12 @@ // Run with: npm test (tsx --experimental-test-module-mocks --test) -import { test, mock } from "node:test"; +import { test, mock, beforeEach } from "node:test"; import assert from "node:assert/strict"; import type { BudgetState } from "../src/types.js"; let solanaCalls = 0; let quoteUsd = 0.5; let completedHasUrl = true; +beforeEach(() => { solanaCalls = 0; quoteUsd = 0.5; completedHasUrl = true; }); mock.module("../src/utils/wallet.js", { namedExports: { @@ -16,7 +17,7 @@ mock.module("../src/utils/wallet.js", { }); mock.module("../src/utils/solana-402.js", { namedExports: { - solanaPaidAsyncPost: async (_endpoint: string, _body: unknown, _timeout: number, opts: { onQuote?: (usd: number) => void }) => { + solanaPaidAsyncPost: async (_endpoint: string, _body: unknown, opts: { onQuote?: (usd: number) => void }) => { solanaCalls++; opts.onQuote?.(quoteUsd); return { @@ -70,9 +71,8 @@ test("Solana video uses the async SVM route and reports the settled result", asy assert.equal(budget.spent, 0.5); }); -test("Solana image-to-video rejects image_url plus aspect_ratio before payment", async () => { - solanaCalls = 0; - const { call, budget } = makeHarness(); +test("Solana image-to-video forwards image_url plus aspect_ratio like Base does (the gateway quotes it)", async () => { + const { call } = makeHarness(); const res = await call({ prompt: "a rainy alley", model: "bytedance/seedance-2.5", @@ -80,10 +80,17 @@ test("Solana image-to-video rejects image_url plus aspect_ratio before payment", image_url: "https://example.com/keyframe.png", aspect_ratio: "16:9", }); - assert.equal(res.isError, true); - assert.match(res.content[0].text, /do not also pass aspect_ratio/); - assert.equal(solanaCalls, 0); - assert.equal(budget.spent, 0); + assert.notEqual(res.isError, true, res.content?.[0]?.text); + assert.equal(solanaCalls, 1); +}); + +test("a higher Solana quote within the cap swaps the reservation without double-booking", async () => { + quoteUsd = 5; + const { call, budget } = makeHarness(10); + const res = await call({ prompt: "a rainy alley", model: "bytedance/seedance-2.5", duration_seconds: 4 }); + assert.notEqual(res.isError, true, res.content?.[0]?.text); + assert.equal(res.structuredContent.cost_usd, 5); + assert.equal(budget.spent, 5, "reservation released, actual booked exactly once"); }); test("the authoritative Solana quote is re-checked against the budget before signing", async () => { @@ -93,6 +100,7 @@ test("the authoritative Solana quote is re-checked against the budget before sig const res = await call({ prompt: "a rainy alley", model: "bytedance/seedance-2.5", duration_seconds: 4 }); assert.equal(res.isError, true); assert.match(res.content[0].text, /budget|limit/i); + assert.doesNotMatch(res.content[0].text, /needs funding/i, "a budget cap is not a funding problem"); assert.equal(budget.spent, 0); });