Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/tools/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions src/tools/music.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
8 changes: 6 additions & 2 deletions src/tools/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions src/utils/poll-ownership.ts
Original file line number Diff line number Diff line change
@@ -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:<kind>:<job id from the poll_url path>:<unix seconds>"
// headers x-poll-wallet base58 32-byte public key of the submitting wallet
// x-poll-timestamp the <unix seconds> 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 <kind>; 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<string, string>;
}

/**
* 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),
};
},
};
}
Loading
Loading