mtok-relay 0.1.10 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reference seller relay for [mtok.market](https://mtok.market). Run with:
4
4
 
5
- npx mtok-relay --offer <id> --model <id> --upstream <url>
5
+ npx mtok-relay --offer <id> --model <id> --upstream <url> --out-price <positive-usd-per-MTok>
6
6
 
7
7
 
8
8
  ---
@@ -3,7 +3,7 @@
3
3
 
4
4
  // node_modules/@noble/hashes/esm/cryptoNode.js
5
5
  import * as nc from "node:crypto";
6
- var crypto = nc && typeof nc === "object" && "webcrypto" in nc ? nc.webcrypto : nc && typeof nc === "object" && "randomBytes" in nc ? nc : void 0;
6
+ var crypto2 = nc && typeof nc === "object" && "webcrypto" in nc ? nc.webcrypto : nc && typeof nc === "object" && "randomBytes" in nc ? nc : void 0;
7
7
 
8
8
  // node_modules/@noble/hashes/esm/utils.js
9
9
  function isBytes(a) {
@@ -100,11 +100,11 @@ function createHasher(hashCons) {
100
100
  return hashC;
101
101
  }
102
102
  function randomBytes(bytesLength = 32) {
103
- if (crypto && typeof crypto.getRandomValues === "function") {
104
- return crypto.getRandomValues(new Uint8Array(bytesLength));
103
+ if (crypto2 && typeof crypto2.getRandomValues === "function") {
104
+ return crypto2.getRandomValues(new Uint8Array(bytesLength));
105
105
  }
106
- if (crypto && typeof crypto.randomBytes === "function") {
107
- return Uint8Array.from(crypto.randomBytes(bytesLength));
106
+ if (crypto2 && typeof crypto2.randomBytes === "function") {
107
+ return Uint8Array.from(crypto2.randomBytes(bytesLength));
108
108
  }
109
109
  throw new Error("crypto.getRandomValues must be defined");
110
110
  }
@@ -2157,7 +2157,7 @@ var secp256k1 = createCurve({
2157
2157
  }, sha256);
2158
2158
 
2159
2159
  // node_modules/viem/_esm/errors/version.js
2160
- var version = "2.54.6";
2160
+ var version = "2.55.1";
2161
2161
 
2162
2162
  // node_modules/viem/_esm/errors/base.js
2163
2163
  var errorConfig = {
@@ -4646,15 +4646,22 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
4646
4646
  const rpcFlag = flag(argv, "--rpc");
4647
4647
  const settlementPubkeyFlag = flag(argv, "--settlement-pubkey");
4648
4648
  const sellerAgentId = flag(argv, "--seller-agent");
4649
- const outPrice = Number(flag(argv, "--out-price") ?? 0);
4649
+ const outPrice = Number(flag(argv, "--out-price"));
4650
4650
  const inPrice = Number(flag(argv, "--in-price") ?? outPrice);
4651
4651
  const redemptionFlag = flag(argv, "--redemption-file") ?? env.RELAY_REDEMPTION_FILE;
4652
- const redemptionFile = redemptionFlag === void 0 ? "./.mtok-redemption.jsonl" : redemptionFlag || null;
4652
+ if (redemptionFlag === "") throw new Error("--redemption-file cannot be empty; paid serves require durable redemption");
4653
+ const redemptionFile = redemptionFlag === void 0 ? "./.mtok-redemption.jsonl" : redemptionFlag;
4653
4654
  const denylistRaw = flag(argv, "--payer-denylist") ?? env.RELAY_PAYER_DENYLIST ?? "";
4654
4655
  const payerDenylist = String(denylistRaw).split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
4655
4656
  if (!offerId) throw new Error("--offer <id> is required");
4656
4657
  if (!model) throw new Error("--model <id> is required (the offer model you serve)");
4657
4658
  if (!upstream) throw new Error("--upstream <url> is required");
4659
+ if (!Number.isFinite(outPrice) || outPrice <= 0) {
4660
+ throw new Error("--out-price <usd/MTok> is required and must be finite and positive");
4661
+ }
4662
+ if (!Number.isFinite(inPrice) || inPrice <= 0) {
4663
+ throw new Error("--in-price <usd/MTok> must be finite and positive");
4664
+ }
4658
4665
  const mtokApiKey = env.MTOK_API_KEY;
4659
4666
  const upstreamKey = env.UPSTREAM_KEY;
4660
4667
  const relayWalletKey = env.RELAY_WALLET_KEY;
@@ -4771,66 +4778,125 @@ function startRelayServer({ config, handleDraw }) {
4771
4778
  return server;
4772
4779
  }
4773
4780
 
4774
- // lib.mjs
4775
- function enforceModelEcho(upstreamModel, offerModel) {
4776
- if (String(upstreamModel) !== String(offerModel))
4777
- throw new Error(`model mismatch: upstream ${upstreamModel} != offer ${offerModel}`);
4778
- }
4779
- function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
4780
- const bps = BigInt(Math.trunc(Math.max(0, Number(feeBps) || 0)));
4781
- if (!feeAddress || bps === 0n) return 0n;
4782
- return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
4783
- }
4784
- var CHARS_PER_TOKEN_EST = 3.2;
4785
- function estimateInputTokens(messages) {
4786
- let chars = 0;
4787
- for (const m of messages ?? []) {
4788
- const c = m?.content;
4789
- if (typeof c === "string") chars += c.length;
4790
- else if (Array.isArray(c)) for (const part of c) chars += String(part?.text ?? "").length;
4791
- }
4792
- return Math.ceil(chars / CHARS_PER_TOKEN_EST);
4793
- }
4794
- function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
4795
- const estIn = estimateInputTokens(messages);
4796
- const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
4797
- if (estInCostUsd >= budgetUsd) return { refuse: true, estIn, estInCostUsd };
4798
- const outBudgetUsd = budgetUsd - estInCostUsd;
4799
- let maxTok = contextCeil;
4800
- if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
4801
- if (Number(outPrice) > 0) maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
4802
- return { refuse: false, maxTok: Math.max(1, maxTok), estIn, estInCostUsd };
4803
- }
4804
-
4805
4781
  // src/redemption.mjs
4806
4782
  import fs from "node:fs";
4783
+ import crypto3 from "node:crypto";
4807
4784
  var DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
4808
4785
  function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS, now = () => Date.now(), log = console } = {}) {
4809
4786
  const map = /* @__PURE__ */ new Map();
4787
+ const claimsDir = file ? `${file}.claims` : null;
4810
4788
  let durable = false;
4789
+ let seenVersion = null;
4790
+ const recordFor = (key, entry) => ({
4791
+ k: key,
4792
+ at: entry.at,
4793
+ state: entry.state,
4794
+ ...entry.state === "complete" ? { payload: entry.payload } : {}
4795
+ });
4796
+ const syncWrite = (target, flags, data) => {
4797
+ const fd = fs.openSync(target, flags);
4798
+ try {
4799
+ fs.writeFileSync(fd, data);
4800
+ fs.fsyncSync(fd);
4801
+ } finally {
4802
+ fs.closeSync(fd);
4803
+ }
4804
+ };
4805
+ const readRecords = () => {
4806
+ const loaded = /* @__PURE__ */ new Map();
4807
+ if (!fs.existsSync(file)) return loaded;
4808
+ const cutoff = now() - retentionMs;
4809
+ let lineNumber = 0;
4810
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
4811
+ lineNumber += 1;
4812
+ if (!line.trim()) continue;
4813
+ let parsed;
4814
+ try {
4815
+ parsed = JSON.parse(line);
4816
+ } catch {
4817
+ throw new Error(`malformed redemption record at line ${lineNumber}`);
4818
+ }
4819
+ const { k, at } = parsed;
4820
+ if (typeof k !== "string" || !k || !Number.isFinite(Number(at))) {
4821
+ throw new Error(`malformed redemption record at line ${lineNumber}`);
4822
+ }
4823
+ if (Number(at) < cutoff) continue;
4824
+ const state2 = parsed.state === "pending" ? "pending" : "complete";
4825
+ loaded.set(k, { state: state2, at: Number(at), ...state2 === "complete" ? { payload: parsed.payload } : {} });
4826
+ }
4827
+ return loaded;
4828
+ };
4829
+ const fileVersion = () => {
4830
+ try {
4831
+ const stat = fs.statSync(file);
4832
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
4833
+ } catch {
4834
+ return null;
4835
+ }
4836
+ };
4811
4837
  if (file) {
4812
4838
  try {
4813
- if (fs.existsSync(file)) {
4814
- const cutoff = now() - retentionMs;
4815
- for (const line of fs.readFileSync(file, "utf8").split("\n")) {
4816
- if (!line.trim()) continue;
4817
- try {
4818
- const { k, at, payload } = JSON.parse(line);
4819
- if (k && Number(at) >= cutoff) map.set(k, { payload, at: Number(at) });
4820
- } catch {
4821
- }
4839
+ for (const [key, entry] of readRecords()) map.set(key, entry);
4840
+ const compact = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
4841
+ try {
4842
+ syncWrite(compact, "w", [...map].map(([k, e]) => JSON.stringify(recordFor(k, e))).join("\n") + (map.size ? "\n" : ""));
4843
+ fs.renameSync(compact, file);
4844
+ } finally {
4845
+ fs.rmSync(compact, { force: true });
4846
+ }
4847
+ fs.mkdirSync(claimsDir, { recursive: true });
4848
+ const markerCutoff = Date.now() - retentionMs;
4849
+ for (const name of fs.readdirSync(claimsDir)) {
4850
+ try {
4851
+ const marker = `${claimsDir}/${name}`;
4852
+ if (fs.statSync(marker).mtimeMs < markerCutoff) fs.unlinkSync(marker);
4853
+ } catch {
4822
4854
  }
4823
4855
  }
4824
- fs.writeFileSync(file, [...map].map(([k, e]) => JSON.stringify({ k, at: e.at, payload: e.payload })).join("\n") + (map.size ? "\n" : ""));
4825
4856
  durable = true;
4857
+ seenVersion = fileVersion();
4826
4858
  log.log?.(`mtok-relay: durable redemption at ${file} (${map.size} entries loaded)`);
4827
4859
  } catch (e) {
4828
- log.warn?.(`mtok-relay: redemption file ${file} not writable (${e.message}); redemption is IN-MEMORY ONLY, a restart can re-serve a paid draw (#495). Point --redemption-file at a durable path.`);
4860
+ map.clear();
4861
+ log.warn?.(`mtok-relay: redemption file ${file} not writable (${e.message}); paid serves will fail closed. Point --redemption-file at a durable path.`);
4829
4862
  durable = false;
4830
4863
  }
4831
4864
  } else {
4832
- log.warn?.("mtok-relay: no --redemption-file, redemption is IN-MEMORY ONLY, a restart or long idle can re-serve one payment for a fresh inference (#495). Set --redemption-file to a durable path.");
4865
+ log.warn?.("mtok-relay: no --redemption-file; paid serves will fail closed because a claim cannot be persisted. Set --redemption-file to a durable path.");
4833
4866
  }
4867
+ const append = (key, entry) => {
4868
+ if (!durable) throw new Error("durable redemption unavailable");
4869
+ syncWrite(file, "a", JSON.stringify(recordFor(key, entry)) + "\n");
4870
+ seenVersion = fileVersion();
4871
+ };
4872
+ const markerFor = (key) => `${claimsDir}/${crypto3.createHash("sha256").update(String(key)).digest("hex")}`;
4873
+ const markClaimed = (key) => {
4874
+ if (!durable) throw new Error("durable redemption unavailable");
4875
+ const marker = markerFor(key);
4876
+ try {
4877
+ syncWrite(marker, "wx", String(key) + "\n");
4878
+ return true;
4879
+ } catch (e) {
4880
+ if (e?.code === "EEXIST") return false;
4881
+ throw e;
4882
+ }
4883
+ };
4884
+ const refresh = () => {
4885
+ if (!durable) return;
4886
+ const version2 = fileVersion();
4887
+ if (!version2 || version2 === seenVersion) return;
4888
+ try {
4889
+ for (const [key, entry] of readRecords()) map.set(key, entry);
4890
+ seenVersion = version2;
4891
+ } catch (e) {
4892
+ log.warn?.(`mtok-relay: could not refresh redemption file ${file} (${e.message}); keeping the existing fail-closed state`);
4893
+ }
4894
+ };
4895
+ const state = (key) => {
4896
+ const current = map.get(key)?.state;
4897
+ if (durable && (!current || current === "pending")) refresh();
4898
+ return map.get(key)?.state ?? null;
4899
+ };
4834
4900
  return {
4835
4901
  durable,
4836
4902
  retentionMs,
@@ -4838,18 +4904,30 @@ function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS
4838
4904
  has(key) {
4839
4905
  return map.has(key);
4840
4906
  },
4907
+ state,
4841
4908
  get(key) {
4842
- return map.get(key)?.payload;
4909
+ return state(key) === "complete" ? map.get(key).payload : void 0;
4910
+ },
4911
+ claim(key, markerKey = key) {
4912
+ if (map.has(key)) return false;
4913
+ if (!markClaimed(markerKey)) return false;
4914
+ const entry = { state: "pending", at: now() };
4915
+ append(key, entry);
4916
+ map.set(key, entry);
4917
+ return true;
4843
4918
  },
4919
+ complete(key, payload) {
4920
+ if (map.get(key)?.state !== "pending") throw new Error("redemption is not pending");
4921
+ const entry = { state: "complete", payload, at: now() };
4922
+ append(key, entry);
4923
+ map.set(key, entry);
4924
+ },
4925
+ // Backward-compatible store API for callers which only persist completed
4926
+ // payloads. The relay runtime itself always uses claim() then complete().
4844
4927
  set(key, payload) {
4845
- const at = now();
4846
- map.set(key, { payload, at });
4847
- if (durable) {
4848
- try {
4849
- fs.appendFileSync(file, JSON.stringify({ k: key, at, payload }) + "\n");
4850
- } catch {
4851
- }
4852
- }
4928
+ const entry = { state: "complete", payload, at: now() };
4929
+ if (durable) append(key, entry);
4930
+ map.set(key, entry);
4853
4931
  }
4854
4932
  };
4855
4933
  }
@@ -5124,6 +5202,263 @@ function createOnchainVerifier({
5124
5202
  };
5125
5203
  }
5126
5204
 
5205
+ // bridge/serve-core.mjs
5206
+ var BALANCE_EPSILON = 1e-6;
5207
+ var REQUEST_NONCE_RE = /^0x[0-9a-fA-F]{32}$/;
5208
+ var CHAT_ROLES = /* @__PURE__ */ new Set(["developer", "system", "user", "assistant"]);
5209
+ var REQUEST_KEYS = /* @__PURE__ */ new Set(["model", "messages", "max_tokens", "temperature", "response_format", "stream", "n"]);
5210
+ async function hash32(v) {
5211
+ const bytes = new TextEncoder().encode(typeof v === "string" ? v : JSON.stringify(v ?? null));
5212
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
5213
+ return "0x" + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
5214
+ }
5215
+ function legacyContentText(content) {
5216
+ const render = (part) => {
5217
+ if (typeof part === "string") return part;
5218
+ if (Array.isArray(part)) return part.map(render).filter(Boolean).join("\n");
5219
+ if (part == null) return "";
5220
+ if (typeof part !== "object") return String(part);
5221
+ if (typeof part.text === "string") return part.text;
5222
+ if (typeof part.content === "string") return part.content;
5223
+ const type = typeof part.type === "string" && /^[a-z0-9_-]{1,32}$/i.test(part.type) ? part.type : "non-text";
5224
+ return `[${type} omitted]`;
5225
+ };
5226
+ return render(content) || "[empty legacy content]";
5227
+ }
5228
+ function sanitizeLegacyMessage(message) {
5229
+ const source = message && typeof message === "object" && !Array.isArray(message) ? message : { content: message };
5230
+ if (CHAT_ROLES.has(source.role)) return { role: source.role, content: legacyContentText(source.content) };
5231
+ const label = source.role === "tool" || source.role === "function" ? source.role : "legacy";
5232
+ return { role: "user", content: `[${label} message]
5233
+ ${legacyContentText(source.content)}` };
5234
+ }
5235
+ function validateRequest(request, model, { legacy = false } = {}) {
5236
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
5237
+ return { error: "request must be an object" };
5238
+ }
5239
+ const unknown = Object.keys(request).find((key) => !REQUEST_KEYS.has(key));
5240
+ if (unknown && !legacy) return { error: `unsupported request field: ${unknown}` };
5241
+ if (!legacy && request.model != null && String(request.model) !== String(model)) {
5242
+ return { error: `request model ${request.model} is not served here` };
5243
+ }
5244
+ if (!Array.isArray(request.messages) || request.messages.length === 0) {
5245
+ return { error: "request.messages must be a nonempty array" };
5246
+ }
5247
+ if (!legacy) {
5248
+ for (const message of request.messages) {
5249
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
5250
+ return { error: "each message must be an object" };
5251
+ }
5252
+ const extra = Object.keys(message).find((key) => key !== "role" && key !== "content");
5253
+ if (extra) return { error: `unsupported message field: ${extra}` };
5254
+ if (!CHAT_ROLES.has(message.role)) return { error: `unsupported message role: ${message.role}` };
5255
+ if (typeof message.content !== "string") return { error: "message content must be plain text" };
5256
+ }
5257
+ }
5258
+ if (!legacy && request.stream != null && request.stream !== false) return { error: "streaming is not supported" };
5259
+ if (!legacy && request.n != null && request.n !== 1) return { error: "request.n must be 1" };
5260
+ const validMaxTokens = Number.isInteger(request.max_tokens) && request.max_tokens > 0;
5261
+ if (!legacy && request.max_tokens != null && !validMaxTokens) {
5262
+ return { error: "max_tokens must be a positive integer" };
5263
+ }
5264
+ const validTemperature = Number.isFinite(request.temperature) && request.temperature >= 0 && request.temperature <= 2;
5265
+ if (!legacy && request.temperature != null && !validTemperature) {
5266
+ return { error: "temperature must be between 0 and 2" };
5267
+ }
5268
+ let validResponseFormat = false;
5269
+ if (request.response_format != null) {
5270
+ const format = request.response_format;
5271
+ validResponseFormat = !!format && typeof format === "object" && !Array.isArray(format) && Object.keys(format).length === 1 && ["json_object", "text"].includes(format.type);
5272
+ if (!legacy && !validResponseFormat) {
5273
+ return { error: 'response_format must be exactly { type: "json_object" } or { type: "text" }' };
5274
+ }
5275
+ }
5276
+ return {
5277
+ safeRequest: {
5278
+ model,
5279
+ messages: legacy ? request.messages.map(sanitizeLegacyMessage) : request.messages.map(({ role, content }) => ({ role, content })),
5280
+ ...validMaxTokens ? { max_tokens: request.max_tokens } : {},
5281
+ ...validTemperature ? { temperature: request.temperature } : {},
5282
+ ...validResponseFormat ? { response_format: { type: request.response_format.type } } : {}
5283
+ }
5284
+ };
5285
+ }
5286
+ function enforceModelEcho(upstreamModel, offerModel) {
5287
+ if (String(upstreamModel) !== String(offerModel))
5288
+ throw new Error(`model mismatch: upstream ${upstreamModel} != offer ${offerModel}`);
5289
+ }
5290
+ function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
5291
+ const bps = BigInt(Math.trunc(Math.max(0, Number(feeBps) || 0)));
5292
+ if (!feeAddress || bps === 0n) return 0n;
5293
+ return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
5294
+ }
5295
+ var MESSAGE_OVERHEAD_TOKENS = 4;
5296
+ function estimateInputTokens(messages) {
5297
+ const utf8 = new TextEncoder();
5298
+ let tokens = 3;
5299
+ for (const m of messages ?? []) {
5300
+ tokens += MESSAGE_OVERHEAD_TOKENS;
5301
+ tokens += utf8.encode(String(m?.role ?? "")).length;
5302
+ tokens += utf8.encode(typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? null)).length;
5303
+ }
5304
+ return tokens;
5305
+ }
5306
+ function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
5307
+ const estIn = estimateInputTokens(messages);
5308
+ const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
5309
+ if (estInCostUsd >= budgetUsd) return { refuse: true, reason: "input", estIn, estInCostUsd };
5310
+ const outBudgetUsd = budgetUsd - estInCostUsd;
5311
+ let maxTok = contextCeil;
5312
+ if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
5313
+ if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
5314
+ return { refuse: true, reason: "output_price", estIn, estInCostUsd };
5315
+ }
5316
+ maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
5317
+ if (maxTok < 1) return { refuse: true, reason: "output", estIn, estInCostUsd };
5318
+ return { refuse: false, maxTok, estIn, estInCostUsd };
5319
+ }
5320
+ function createServeCore({
5321
+ model,
5322
+ inPrice,
5323
+ outPrice,
5324
+ verifier,
5325
+ redemption,
5326
+ upstream,
5327
+ log,
5328
+ offerId,
5329
+ sellerAgentId,
5330
+ sellerWallet,
5331
+ dripContractAddress,
5332
+ feeRecipient,
5333
+ feeBps,
5334
+ screenPayer
5335
+ }) {
5336
+ const serve = async (body) => {
5337
+ const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
5338
+ const hasRequestNonce = Object.hasOwn(body, "requestNonce");
5339
+ if (!bookingId) return { status: 400, body: { error: "bad_request", detail: "DRAW needs bookingId" } };
5340
+ if (n == null) return { status: 400, body: { error: "bad_request", detail: "DRAW needs a delivery index n (per-booking idempotency key)" } };
5341
+ if (!Number.isSafeInteger(n) || n < 0 || n > 4294967295) {
5342
+ return { status: 400, body: { error: "bad_request", detail: "DRAW delivery index n must be a nonnegative uint32 integer" } };
5343
+ }
5344
+ if (hasRequestNonce && !REQUEST_NONCE_RE.test(requestNonce)) {
5345
+ return { status: 400, body: { error: "bad_request", detail: "DRAW needs requestNonce as 16 random bytes encoded as 0x-prefixed hex" } };
5346
+ }
5347
+ const checked = validateRequest(request, model, { legacy: !hasRequestNonce });
5348
+ if (checked.error) return { status: 400, body: { error: "bad_request", detail: checked.error } };
5349
+ const requestHashScheme = hasRequestNonce ? "nonce-v1" : "legacy-v0";
5350
+ const requestHash = hasRequestNonce ? await hash32({ request, requestNonce }) : await hash32(request);
5351
+ const cacheKey = `${requestHashScheme}:${bookingId}:${n}:${requestHash}`;
5352
+ const oldLegacyKey = hasRequestNonce ? null : `${bookingId}:${n}:${requestHash}`;
5353
+ if (!dripContractAddress) {
5354
+ return { status: 402, body: { error: "contract_mode_required", detail: "this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress" } };
5355
+ }
5356
+ if (!drawPaidTxHash) return { status: 402, body: { error: "draw_payment_required", detail: "contract mode requires drawPaidTxHash before upstream delivery" } };
5357
+ let paid;
5358
+ try {
5359
+ paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
5360
+ contractAddress: dripContractAddress,
5361
+ buyerAgentId: buyerId,
5362
+ sellerAgentId,
5363
+ // when set, enforces the offer-owner match (#codex review)
5364
+ bookingId,
5365
+ offerId,
5366
+ model,
5367
+ n,
5368
+ requestHash,
5369
+ sellerWallet,
5370
+ feeRecipient,
5371
+ // #580: refuse a payment older than the redemption window. The JSONL
5372
+ // payload cache AND the claim markers are both aged out at boot (#600),
5373
+ // so past retention this age bound is the sole replay defense (its
5374
+ // skip-on-unreadable-block residual is named in redemption.mjs). An
5375
+ // honest retry is seconds-to-minutes old, never days.
5376
+ maxPaidAgeMs: redemption.retentionMs
5377
+ });
5378
+ } catch (e) {
5379
+ return { status: 402, body: { error: "payment_unverified", detail: e.message } };
5380
+ }
5381
+ if (!paid?.ok) return { status: 402, body: { error: "payment_unverified", detail: paid?.reason || "unknown" } };
5382
+ const expectedFee = configuredFeeAtomic({
5383
+ sellerUsdAtomic: paid.event.sellerUsdAtomic,
5384
+ feeAddress: feeRecipient,
5385
+ feeBps
5386
+ });
5387
+ if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
5388
+ return { status: 402, body: { error: "payment_unverified", detail: "fee_amount_too_low" } };
5389
+ }
5390
+ if (screenPayer) {
5391
+ try {
5392
+ if (await screenPayer(String(paid.from || "").toLowerCase())) {
5393
+ return { status: 403, body: { error: "payer_denied", detail: "the verified payer wallet is denylisted by this relay" } };
5394
+ }
5395
+ } catch (e) {
5396
+ return { status: 403, body: { error: "payer_denied", detail: "payer screening failed: " + e.message } };
5397
+ }
5398
+ }
5399
+ let storedKey = cacheKey;
5400
+ let redemptionState = await redemption.state(storedKey);
5401
+ if (!redemptionState && oldLegacyKey) {
5402
+ const oldLegacyState = await redemption.state(oldLegacyKey);
5403
+ if (oldLegacyState) {
5404
+ storedKey = oldLegacyKey;
5405
+ redemptionState = oldLegacyState;
5406
+ } else {
5407
+ redemptionState = await redemption.state(cacheKey);
5408
+ }
5409
+ }
5410
+ if (redemptionState === "complete") return { status: 200, body: await redemption.get(storedKey) };
5411
+ if (redemptionState === "pending") {
5412
+ return { status: 409, body: { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId } };
5413
+ }
5414
+ const paidEvent = paid.event;
5415
+ const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
5416
+ if (remainingUsd < BALANCE_EPSILON) {
5417
+ return { status: 402, body: { error: "balance_exhausted", detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
5418
+ }
5419
+ const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
5420
+ const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
5421
+ const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
5422
+ const boundOutPrice = Math.max(Number(outPrice) || 0, eventOutPriceUsd);
5423
+ const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice: boundInPrice, outPrice: boundOutPrice, reqMax: checked.safeRequest.max_tokens });
5424
+ if (bound.refuse) {
5425
+ const error = bound.reason === "input" ? "input_too_large" : "output_unfunded";
5426
+ return { status: 402, body: { error, detail: `estimated input (~${bound.estIn} tokens, $${bound.estInCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`, _bookingId: bookingId, remainingUsd } };
5427
+ }
5428
+ const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
5429
+ try {
5430
+ if (!await redemption.claim(cacheKey, oldLegacyKey ?? cacheKey)) {
5431
+ return { status: 409, body: { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId } };
5432
+ }
5433
+ } catch (e) {
5434
+ return { status: 503, body: { error: "redemption_unavailable", detail: `could not durably claim the paid draw: ${e.message}`, _bookingId: bookingId } };
5435
+ }
5436
+ let completion;
5437
+ try {
5438
+ completion = await upstream(safeRequest);
5439
+ } catch (e) {
5440
+ return { status: 502, body: { error: "upstream_error", detail: e.message } };
5441
+ }
5442
+ try {
5443
+ enforceModelEcho(completion.model, model);
5444
+ } catch (e) {
5445
+ return { status: 502, body: { error: "model_mismatch", detail: e.message } };
5446
+ }
5447
+ const usage = completion.usage ?? {};
5448
+ const inTok = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0;
5449
+ const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
5450
+ const usedUsd = (inTok * Number(paidEvent.inputPricePerMTokAtomic || 0) + outTok * Number(paidEvent.outputPricePerMTokAtomic || 0)) / 1e12;
5451
+ const payload = { ...completion, _bookingId: bookingId, remainingUsd: Math.max(0, Math.round((remainingUsd - usedUsd) * 1e6) / 1e6) };
5452
+ try {
5453
+ await redemption.complete(cacheKey, payload);
5454
+ } catch (e) {
5455
+ (log ?? console).error?.(`mtok serve core: completion for ${cacheKey} could not be persisted (${e.message}); retries will remain pending`);
5456
+ }
5457
+ return { status: 200, body: payload };
5458
+ };
5459
+ return { serve };
5460
+ }
5461
+
5127
5462
  // bridge/bridge.mjs
5128
5463
  function httpUpstream({ baseUrl, key }) {
5129
5464
  const url = String(baseUrl || "").replace(/\/$/, "") + "/chat/completions";
@@ -5162,11 +5497,8 @@ var BASE_SEPOLIA_RPCS = [
5162
5497
  var rpcUrlsFor = (chainId, override) => override ? [override] : Number(chainId) === 8453 ? BASE_MAINNET_RPCS : BASE_SEPOLIA_RPCS;
5163
5498
 
5164
5499
  // src/runtime.mjs
5165
- import crypto2 from "node:crypto";
5166
- var BALANCE_EPSILON = 1e-6;
5167
- var hash32 = (v) => "0x" + crypto2.createHash("sha256").update(typeof v === "string" ? v : JSON.stringify(v ?? null)).digest("hex");
5168
5500
  async function createRelayRuntime(config) {
5169
- const served = createRedemptionStore({ file: config.redemptionFile });
5501
+ const served = createRedemptionStore({ file: config.redemptionFile, log: config.log });
5170
5502
  const drawLocks = /* @__PURE__ */ new Map();
5171
5503
  const platform = await fetchPlatformConfig(config);
5172
5504
  const verifier = createOnchainVerifier({ rpcUrls: rpcUrlsFor(platform.chainId, config.rpcFlag), usdcAddress: platform.usdcAddress, expectedChainId: platform.chainId });
@@ -5180,6 +5512,22 @@ async function createRelayRuntime(config) {
5180
5512
  if (screenPayer && await screenPayer(payer)) return true;
5181
5513
  return false;
5182
5514
  };
5515
+ const core = createServeCore({
5516
+ model: config.model,
5517
+ inPrice: config.inPrice,
5518
+ outPrice: config.outPrice,
5519
+ verifier,
5520
+ redemption: served,
5521
+ upstream,
5522
+ log: config.log,
5523
+ offerId: config.offerId,
5524
+ sellerAgentId: config.sellerAgentId,
5525
+ sellerWallet: config.settlementAddr,
5526
+ dripContractAddress: platform.dripContractAddress,
5527
+ feeRecipient: platform.feeAddress,
5528
+ feeBps: platform.feeBps,
5529
+ screenPayer: payerDenied
5530
+ });
5183
5531
  const withBookingLock = async (bookingId, fn) => {
5184
5532
  const previous = drawLocks.get(bookingId) || Promise.resolve();
5185
5533
  let release;
@@ -5198,89 +5546,10 @@ async function createRelayRuntime(config) {
5198
5546
  if (drawLocks.get(bookingId) === tail) drawLocks.delete(bookingId);
5199
5547
  }
5200
5548
  };
5201
- const handleDraw = async (body, res) => {
5202
- const { bookingId, n, buyerId, request, drawPaidTxHash } = body;
5203
- if (!bookingId) return send(res, 400, { error: "bad_request", detail: "DRAW needs bookingId" });
5204
- if (n == null) return send(res, 400, { error: "bad_request", detail: "DRAW needs a delivery index n (per-booking idempotency key)" });
5205
- return withBookingLock(bookingId, async () => {
5206
- const requestHash = hash32(request);
5207
- const cacheKey = `${bookingId}:${n}:${requestHash}`;
5208
- if (!platform.dripContractAddress) {
5209
- return send(res, 402, { error: "contract_mode_required", detail: "this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress" });
5210
- }
5211
- if (!drawPaidTxHash) return send(res, 402, { error: "draw_payment_required", detail: "contract mode requires drawPaidTxHash before upstream delivery" });
5212
- let paid;
5213
- try {
5214
- paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
5215
- contractAddress: platform.dripContractAddress,
5216
- buyerAgentId: buyerId,
5217
- sellerAgentId: config.sellerAgentId,
5218
- // when set, enforces the offer-owner match (#codex review)
5219
- bookingId,
5220
- offerId: config.offerId,
5221
- model: config.model,
5222
- n,
5223
- requestHash,
5224
- sellerWallet: config.settlementAddr,
5225
- feeRecipient: platform.feeAddress,
5226
- // #580: refuse a payment older than the redemption window. served.has() is the
5227
- // primary one-serve guard, but it's pruned by age and lost on an in-memory restart;
5228
- // this on-chain age bound closes the re-serve hole those cases open (a stale replay
5229
- // buying a fresh inference). An honest retry is seconds-to-minutes old, never days.
5230
- maxPaidAgeMs: served.retentionMs
5231
- });
5232
- } catch (e) {
5233
- return send(res, 402, { error: "payment_unverified", detail: e.message });
5234
- }
5235
- if (!paid?.ok) return send(res, 402, { error: "payment_unverified", detail: paid?.reason || "unknown" });
5236
- const expectedFee = configuredFeeAtomic({
5237
- sellerUsdAtomic: paid.event.sellerUsdAtomic,
5238
- feeAddress: platform.feeAddress,
5239
- feeBps: platform.feeBps
5240
- });
5241
- if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
5242
- return send(res, 402, { error: "payment_unverified", detail: "fee_amount_too_low" });
5243
- }
5244
- try {
5245
- if (await payerDenied(String(paid.from || "").toLowerCase())) {
5246
- return send(res, 403, { error: "payer_denied", detail: "the verified payer wallet is denylisted by this relay" });
5247
- }
5248
- } catch (e) {
5249
- return send(res, 403, { error: "payer_denied", detail: "payer screening failed: " + e.message });
5250
- }
5251
- if (served.has(cacheKey)) return send(res, 200, served.get(cacheKey));
5252
- const paidEvent = paid.event;
5253
- const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
5254
- if (remainingUsd <= BALANCE_EPSILON) {
5255
- return send(res, 402, { error: "balance_exhausted", detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd });
5256
- }
5257
- const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
5258
- const inPrice = Math.max(Number(config.inPrice) || 0, eventInPriceUsd);
5259
- const bound = boundServe({ messages: request?.messages, budgetUsd: remainingUsd, inPrice, outPrice: config.outPrice, reqMax: Number(request?.max_tokens) });
5260
- if (bound.refuse) {
5261
- return send(res, 402, { error: "input_too_large", detail: `estimated input (~${bound.estIn} tokens, $${bound.estInCostUsd.toFixed(6)}) meets or exceeds the paid amount ($${remainingUsd}); send a shorter prompt or pay for more`, _bookingId: bookingId, remainingUsd });
5262
- }
5263
- const safeRequest = { ...request, max_tokens: bound.maxTok };
5264
- let completion;
5265
- try {
5266
- completion = await upstream(safeRequest);
5267
- } catch (e) {
5268
- return send(res, 502, { error: "upstream_error", detail: e.message });
5269
- }
5270
- try {
5271
- enforceModelEcho(completion.model, config.model);
5272
- } catch (e) {
5273
- return send(res, 502, { error: "model_mismatch", detail: e.message });
5274
- }
5275
- const usage = completion.usage ?? {};
5276
- const inTok = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0;
5277
- const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
5278
- const usedUsd = (inTok * Number(paidEvent.inputPricePerMTokAtomic || 0) + outTok * Number(paidEvent.outputPricePerMTokAtomic || 0)) / 1e12;
5279
- const payload = { ...completion, _bookingId: bookingId, remainingUsd: Math.max(0, Math.round((remainingUsd - usedUsd) * 1e6) / 1e6) };
5280
- served.set(cacheKey, payload);
5281
- return send(res, 200, payload);
5282
- });
5283
- };
5549
+ const handleDraw = (body, res) => withBookingLock(String(body?.bookingId ?? ""), async () => {
5550
+ const out = await core.serve(body);
5551
+ return send(res, out.status, out.body);
5552
+ });
5284
5553
  return { handleDraw };
5285
5554
  }
5286
5555
  async function fetchPlatformConfig(config) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mtok-relay",
3
- "version": "0.1.10",
4
- "description": "Reference seller relay for mtok.market — accepts on-chain-prepaid chunk draws and serves inference from an upstream you control. Run with: npx mtok-relay --offer <id> --model <id> --upstream <url>.",
3
+ "version": "0.2.1",
4
+ "description": "Reference seller relay for mtok.market — accepts on-chain-prepaid chunk draws and serves inference from an upstream you control.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mtok-relay": "dist/mtok-relay.mjs"