mtok-relay 0.1.9 → 0.2.0

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
  ---
@@ -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.0";
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;
@@ -4781,56 +4788,143 @@ function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
4781
4788
  if (!feeAddress || bps === 0n) return 0n;
4782
4789
  return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
4783
4790
  }
4784
- var CHARS_PER_TOKEN_EST = 3.2;
4791
+ var MESSAGE_OVERHEAD_TOKENS = 4;
4785
4792
  function estimateInputTokens(messages) {
4786
- let chars = 0;
4793
+ const utf8 = new TextEncoder();
4794
+ let tokens = 3;
4787
4795
  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;
4796
+ tokens += MESSAGE_OVERHEAD_TOKENS;
4797
+ tokens += utf8.encode(String(m?.role ?? "")).length;
4798
+ tokens += utf8.encode(typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? null)).length;
4791
4799
  }
4792
- return Math.ceil(chars / CHARS_PER_TOKEN_EST);
4800
+ return tokens;
4793
4801
  }
4794
4802
  function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
4795
4803
  const estIn = estimateInputTokens(messages);
4796
4804
  const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
4797
- if (estInCostUsd >= budgetUsd) return { refuse: true, estIn, estInCostUsd };
4805
+ if (estInCostUsd >= budgetUsd) return { refuse: true, reason: "input", estIn, estInCostUsd };
4798
4806
  const outBudgetUsd = budgetUsd - estInCostUsd;
4799
4807
  let maxTok = contextCeil;
4800
4808
  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 };
4809
+ if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
4810
+ return { refuse: true, reason: "output_price", estIn, estInCostUsd };
4811
+ }
4812
+ maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
4813
+ if (maxTok < 1) return { refuse: true, reason: "output", estIn, estInCostUsd };
4814
+ return { refuse: false, maxTok, estIn, estInCostUsd };
4803
4815
  }
4804
4816
 
4805
4817
  // src/redemption.mjs
4806
4818
  import fs from "node:fs";
4819
+ import crypto2 from "node:crypto";
4807
4820
  var DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
4808
4821
  function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS, now = () => Date.now(), log = console } = {}) {
4809
4822
  const map = /* @__PURE__ */ new Map();
4823
+ const claimsDir = file ? `${file}.claims` : null;
4810
4824
  let durable = false;
4825
+ let seenVersion = null;
4826
+ const recordFor = (key, entry) => ({
4827
+ k: key,
4828
+ at: entry.at,
4829
+ state: entry.state,
4830
+ ...entry.state === "complete" ? { payload: entry.payload } : {}
4831
+ });
4832
+ const syncWrite = (target, flags, data) => {
4833
+ const fd = fs.openSync(target, flags);
4834
+ try {
4835
+ fs.writeFileSync(fd, data);
4836
+ fs.fsyncSync(fd);
4837
+ } finally {
4838
+ fs.closeSync(fd);
4839
+ }
4840
+ };
4841
+ const readRecords = () => {
4842
+ const loaded = /* @__PURE__ */ new Map();
4843
+ if (!fs.existsSync(file)) return loaded;
4844
+ const cutoff = now() - retentionMs;
4845
+ let lineNumber = 0;
4846
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
4847
+ lineNumber += 1;
4848
+ if (!line.trim()) continue;
4849
+ let parsed;
4850
+ try {
4851
+ parsed = JSON.parse(line);
4852
+ } catch {
4853
+ throw new Error(`malformed redemption record at line ${lineNumber}`);
4854
+ }
4855
+ const { k, at } = parsed;
4856
+ if (typeof k !== "string" || !k || !Number.isFinite(Number(at))) {
4857
+ throw new Error(`malformed redemption record at line ${lineNumber}`);
4858
+ }
4859
+ if (Number(at) < cutoff) continue;
4860
+ const state2 = parsed.state === "pending" ? "pending" : "complete";
4861
+ loaded.set(k, { state: state2, at: Number(at), ...state2 === "complete" ? { payload: parsed.payload } : {} });
4862
+ }
4863
+ return loaded;
4864
+ };
4865
+ const fileVersion = () => {
4866
+ try {
4867
+ const stat = fs.statSync(file);
4868
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
4869
+ } catch {
4870
+ return null;
4871
+ }
4872
+ };
4811
4873
  if (file) {
4812
4874
  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
- }
4822
- }
4875
+ for (const [key, entry] of readRecords()) map.set(key, entry);
4876
+ const compact = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
4877
+ try {
4878
+ syncWrite(compact, "w", [...map].map(([k, e]) => JSON.stringify(recordFor(k, e))).join("\n") + (map.size ? "\n" : ""));
4879
+ fs.renameSync(compact, file);
4880
+ } finally {
4881
+ fs.rmSync(compact, { force: true });
4823
4882
  }
4824
- fs.writeFileSync(file, [...map].map(([k, e]) => JSON.stringify({ k, at: e.at, payload: e.payload })).join("\n") + (map.size ? "\n" : ""));
4883
+ fs.mkdirSync(claimsDir, { recursive: true });
4825
4884
  durable = true;
4885
+ seenVersion = fileVersion();
4826
4886
  log.log?.(`mtok-relay: durable redemption at ${file} (${map.size} entries loaded)`);
4827
4887
  } 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.`);
4888
+ map.clear();
4889
+ log.warn?.(`mtok-relay: redemption file ${file} not writable (${e.message}); paid serves will fail closed. Point --redemption-file at a durable path.`);
4829
4890
  durable = false;
4830
4891
  }
4831
4892
  } 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.");
4893
+ 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
4894
  }
4895
+ const append = (key, entry) => {
4896
+ if (!durable) throw new Error("durable redemption unavailable");
4897
+ syncWrite(file, "a", JSON.stringify(recordFor(key, entry)) + "\n");
4898
+ seenVersion = fileVersion();
4899
+ };
4900
+ const markerFor = (key) => `${claimsDir}/${crypto2.createHash("sha256").update(String(key)).digest("hex")}`;
4901
+ const markClaimed = (key) => {
4902
+ if (!durable) throw new Error("durable redemption unavailable");
4903
+ const marker = markerFor(key);
4904
+ try {
4905
+ syncWrite(marker, "wx", String(key) + "\n");
4906
+ return true;
4907
+ } catch (e) {
4908
+ if (e?.code === "EEXIST") return false;
4909
+ throw e;
4910
+ }
4911
+ };
4912
+ const refresh = () => {
4913
+ if (!durable) return;
4914
+ const version2 = fileVersion();
4915
+ if (!version2 || version2 === seenVersion) return;
4916
+ try {
4917
+ for (const [key, entry] of readRecords()) map.set(key, entry);
4918
+ seenVersion = version2;
4919
+ } catch (e) {
4920
+ log.warn?.(`mtok-relay: could not refresh redemption file ${file} (${e.message}); keeping the existing fail-closed state`);
4921
+ }
4922
+ };
4923
+ const state = (key) => {
4924
+ const current = map.get(key)?.state;
4925
+ if (durable && (!current || current === "pending")) refresh();
4926
+ return map.get(key)?.state ?? null;
4927
+ };
4834
4928
  return {
4835
4929
  durable,
4836
4930
  retentionMs,
@@ -4838,18 +4932,30 @@ function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS
4838
4932
  has(key) {
4839
4933
  return map.has(key);
4840
4934
  },
4935
+ state,
4841
4936
  get(key) {
4842
- return map.get(key)?.payload;
4937
+ return state(key) === "complete" ? map.get(key).payload : void 0;
4843
4938
  },
4939
+ claim(key, markerKey = key) {
4940
+ if (map.has(key)) return false;
4941
+ if (!markClaimed(markerKey)) return false;
4942
+ const entry = { state: "pending", at: now() };
4943
+ append(key, entry);
4944
+ map.set(key, entry);
4945
+ return true;
4946
+ },
4947
+ complete(key, payload) {
4948
+ if (map.get(key)?.state !== "pending") throw new Error("redemption is not pending");
4949
+ const entry = { state: "complete", payload, at: now() };
4950
+ append(key, entry);
4951
+ map.set(key, entry);
4952
+ },
4953
+ // Backward-compatible store API for callers which only persist completed
4954
+ // payloads. The relay runtime itself always uses claim() then complete().
4844
4955
  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
- }
4956
+ const entry = { state: "complete", payload, at: now() };
4957
+ if (durable) append(key, entry);
4958
+ map.set(key, entry);
4853
4959
  }
4854
4960
  };
4855
4961
  }
@@ -5090,16 +5196,22 @@ function createOnchainVerifier({
5090
5196
  const maxAge = Number(maxPaidAgeMs);
5091
5197
  if (Number.isFinite(maxAge) && maxAge > 0) {
5092
5198
  const bn = matchedLog?.blockNumber;
5093
- if (bn == null) return { ok: false, reason: "paid_block_unknown" };
5094
- let paidAtMs;
5095
- try {
5199
+ let paidAtMs = null;
5200
+ if (bn != null) {
5096
5201
  const blockTag = typeof bn === "string" && bn.startsWith("0x") ? bn : "0x" + BigInt(bn).toString(16);
5097
- const block = await rpc("eth_getBlockByNumber", [blockTag, false]);
5098
- paidAtMs = Number(BigInt(block.timestamp)) * 1e3;
5099
- } catch {
5100
- return { ok: false, reason: "paid_block_unreadable" };
5202
+ for (let i = 0; i <= receiptRetries; i++) {
5203
+ try {
5204
+ const block = await rpc("eth_getBlockByNumber", [blockTag, false]);
5205
+ if (block?.timestamp != null) {
5206
+ paidAtMs = Number(BigInt(block.timestamp)) * 1e3;
5207
+ break;
5208
+ }
5209
+ } catch {
5210
+ }
5211
+ if (i < receiptRetries) await sleepImpl(receiptRetryMs);
5212
+ }
5101
5213
  }
5102
- if (nowMs() - paidAtMs > maxAge) return { ok: false, reason: "payment_too_old" };
5214
+ if (paidAtMs != null && nowMs() - paidAtMs > maxAge) return { ok: false, reason: "payment_too_old" };
5103
5215
  }
5104
5216
  const consumed = /* @__PURE__ */ new Set();
5105
5217
  let sellerTransfer = null;
@@ -5156,11 +5268,85 @@ var BASE_SEPOLIA_RPCS = [
5156
5268
  var rpcUrlsFor = (chainId, override) => override ? [override] : Number(chainId) === 8453 ? BASE_MAINNET_RPCS : BASE_SEPOLIA_RPCS;
5157
5269
 
5158
5270
  // src/runtime.mjs
5159
- import crypto2 from "node:crypto";
5271
+ import crypto3 from "node:crypto";
5160
5272
  var BALANCE_EPSILON = 1e-6;
5161
- var hash32 = (v) => "0x" + crypto2.createHash("sha256").update(typeof v === "string" ? v : JSON.stringify(v ?? null)).digest("hex");
5273
+ var hash32 = (v) => "0x" + crypto3.createHash("sha256").update(typeof v === "string" ? v : JSON.stringify(v ?? null)).digest("hex");
5274
+ var REQUEST_NONCE_RE = /^0x[0-9a-fA-F]{32}$/;
5275
+ var CHAT_ROLES = /* @__PURE__ */ new Set(["developer", "system", "user", "assistant"]);
5276
+ var REQUEST_KEYS = /* @__PURE__ */ new Set(["model", "messages", "max_tokens", "temperature", "response_format", "stream", "n"]);
5277
+ function legacyContentText(content) {
5278
+ const render = (part) => {
5279
+ if (typeof part === "string") return part;
5280
+ if (Array.isArray(part)) return part.map(render).filter(Boolean).join("\n");
5281
+ if (part == null) return "";
5282
+ if (typeof part !== "object") return String(part);
5283
+ if (typeof part.text === "string") return part.text;
5284
+ if (typeof part.content === "string") return part.content;
5285
+ const type = typeof part.type === "string" && /^[a-z0-9_-]{1,32}$/i.test(part.type) ? part.type : "non-text";
5286
+ return `[${type} omitted]`;
5287
+ };
5288
+ return render(content) || "[empty legacy content]";
5289
+ }
5290
+ function sanitizeLegacyMessage(message) {
5291
+ const source = message && typeof message === "object" && !Array.isArray(message) ? message : { content: message };
5292
+ if (CHAT_ROLES.has(source.role)) return { role: source.role, content: legacyContentText(source.content) };
5293
+ const label = source.role === "tool" || source.role === "function" ? source.role : "legacy";
5294
+ return { role: "user", content: `[${label} message]
5295
+ ${legacyContentText(source.content)}` };
5296
+ }
5297
+ function validateRequest(request, model, { legacy = false } = {}) {
5298
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
5299
+ return { error: "request must be an object" };
5300
+ }
5301
+ const unknown = Object.keys(request).find((key) => !REQUEST_KEYS.has(key));
5302
+ if (unknown && !legacy) return { error: `unsupported request field: ${unknown}` };
5303
+ if (!legacy && request.model != null && String(request.model) !== String(model)) {
5304
+ return { error: `request model ${request.model} is not served here` };
5305
+ }
5306
+ if (!Array.isArray(request.messages) || request.messages.length === 0) {
5307
+ return { error: "request.messages must be a nonempty array" };
5308
+ }
5309
+ if (!legacy) {
5310
+ for (const message of request.messages) {
5311
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
5312
+ return { error: "each message must be an object" };
5313
+ }
5314
+ const extra = Object.keys(message).find((key) => key !== "role" && key !== "content");
5315
+ if (extra) return { error: `unsupported message field: ${extra}` };
5316
+ if (!CHAT_ROLES.has(message.role)) return { error: `unsupported message role: ${message.role}` };
5317
+ if (typeof message.content !== "string") return { error: "message content must be plain text" };
5318
+ }
5319
+ }
5320
+ if (!legacy && request.stream != null && request.stream !== false) return { error: "streaming is not supported" };
5321
+ if (!legacy && request.n != null && request.n !== 1) return { error: "request.n must be 1" };
5322
+ const validMaxTokens = Number.isInteger(request.max_tokens) && request.max_tokens > 0;
5323
+ if (!legacy && request.max_tokens != null && !validMaxTokens) {
5324
+ return { error: "max_tokens must be a positive integer" };
5325
+ }
5326
+ const validTemperature = Number.isFinite(request.temperature) && request.temperature >= 0 && request.temperature <= 2;
5327
+ if (!legacy && request.temperature != null && !validTemperature) {
5328
+ return { error: "temperature must be between 0 and 2" };
5329
+ }
5330
+ let validResponseFormat = false;
5331
+ if (request.response_format != null) {
5332
+ const format = request.response_format;
5333
+ validResponseFormat = !!format && typeof format === "object" && !Array.isArray(format) && Object.keys(format).length === 1 && ["json_object", "text"].includes(format.type);
5334
+ if (!legacy && !validResponseFormat) {
5335
+ return { error: 'response_format must be exactly { type: "json_object" } or { type: "text" }' };
5336
+ }
5337
+ }
5338
+ return {
5339
+ safeRequest: {
5340
+ model,
5341
+ messages: legacy ? request.messages.map(sanitizeLegacyMessage) : request.messages.map(({ role, content }) => ({ role, content })),
5342
+ ...validMaxTokens ? { max_tokens: request.max_tokens } : {},
5343
+ ...validTemperature ? { temperature: request.temperature } : {},
5344
+ ...validResponseFormat ? { response_format: { type: request.response_format.type } } : {}
5345
+ }
5346
+ };
5347
+ }
5162
5348
  async function createRelayRuntime(config) {
5163
- const served = createRedemptionStore({ file: config.redemptionFile });
5349
+ const served = createRedemptionStore({ file: config.redemptionFile, log: config.log });
5164
5350
  const drawLocks = /* @__PURE__ */ new Map();
5165
5351
  const platform = await fetchPlatformConfig(config);
5166
5352
  const verifier = createOnchainVerifier({ rpcUrls: rpcUrlsFor(platform.chainId, config.rpcFlag), usdcAddress: platform.usdcAddress, expectedChainId: platform.chainId });
@@ -5193,12 +5379,23 @@ async function createRelayRuntime(config) {
5193
5379
  }
5194
5380
  };
5195
5381
  const handleDraw = async (body, res) => {
5196
- const { bookingId, n, buyerId, request, drawPaidTxHash } = body;
5382
+ const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
5383
+ const hasRequestNonce = Object.hasOwn(body, "requestNonce");
5197
5384
  if (!bookingId) return send(res, 400, { error: "bad_request", detail: "DRAW needs bookingId" });
5198
5385
  if (n == null) return send(res, 400, { error: "bad_request", detail: "DRAW needs a delivery index n (per-booking idempotency key)" });
5386
+ if (!Number.isSafeInteger(n) || n < 0 || n > 4294967295) {
5387
+ return send(res, 400, { error: "bad_request", detail: "DRAW delivery index n must be a nonnegative uint32 integer" });
5388
+ }
5389
+ if (hasRequestNonce && !REQUEST_NONCE_RE.test(requestNonce)) {
5390
+ return send(res, 400, { error: "bad_request", detail: "DRAW needs requestNonce as 16 random bytes encoded as 0x-prefixed hex" });
5391
+ }
5392
+ const checked = validateRequest(request, config.model, { legacy: !hasRequestNonce });
5393
+ if (checked.error) return send(res, 400, { error: "bad_request", detail: checked.error });
5199
5394
  return withBookingLock(bookingId, async () => {
5200
- const requestHash = hash32(request);
5201
- const cacheKey = `${bookingId}:${n}:${requestHash}`;
5395
+ const requestHashScheme = hasRequestNonce ? "nonce-v1" : "legacy-v0";
5396
+ const requestHash = hasRequestNonce ? hash32({ request, requestNonce }) : hash32(request);
5397
+ const cacheKey = `${requestHashScheme}:${bookingId}:${n}:${requestHash}`;
5398
+ const oldLegacyKey = hasRequestNonce ? null : `${bookingId}:${n}:${requestHash}`;
5202
5399
  if (!platform.dripContractAddress) {
5203
5400
  return send(res, 402, { error: "contract_mode_required", detail: "this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress" });
5204
5401
  }
@@ -5217,10 +5414,9 @@ async function createRelayRuntime(config) {
5217
5414
  requestHash,
5218
5415
  sellerWallet: config.settlementAddr,
5219
5416
  feeRecipient: platform.feeAddress,
5220
- // #580: refuse a payment older than the redemption window. served.has() is the
5221
- // primary one-serve guard, but it's pruned by age and lost on an in-memory restart;
5222
- // this on-chain age bound closes the re-serve hole those cases open (a stale replay
5223
- // buying a fresh inference). An honest retry is seconds-to-minutes old, never days.
5417
+ // #580: refuse a payment older than the redemption window. The JSONL
5418
+ // payload cache is compacted by age (exclusive claim markers remain
5419
+ // fail-closed), and an honest retry is seconds-to-minutes old, never days.
5224
5420
  maxPaidAgeMs: served.retentionMs
5225
5421
  });
5226
5422
  } catch (e) {
@@ -5242,19 +5438,43 @@ async function createRelayRuntime(config) {
5242
5438
  } catch (e) {
5243
5439
  return send(res, 403, { error: "payer_denied", detail: "payer screening failed: " + e.message });
5244
5440
  }
5245
- if (served.has(cacheKey)) return send(res, 200, served.get(cacheKey));
5441
+ let storedKey = cacheKey;
5442
+ let redemptionState = served.state(storedKey);
5443
+ if (!redemptionState && oldLegacyKey) {
5444
+ const oldLegacyState = served.state(oldLegacyKey);
5445
+ if (oldLegacyState) {
5446
+ storedKey = oldLegacyKey;
5447
+ redemptionState = oldLegacyState;
5448
+ } else {
5449
+ redemptionState = served.state(cacheKey);
5450
+ }
5451
+ }
5452
+ if (redemptionState === "complete") return send(res, 200, served.get(storedKey));
5453
+ if (redemptionState === "pending") {
5454
+ return send(res, 409, { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId });
5455
+ }
5246
5456
  const paidEvent = paid.event;
5247
5457
  const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
5248
- if (remainingUsd <= BALANCE_EPSILON) {
5458
+ if (remainingUsd < BALANCE_EPSILON) {
5249
5459
  return send(res, 402, { error: "balance_exhausted", detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd });
5250
5460
  }
5251
5461
  const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
5462
+ const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
5252
5463
  const inPrice = Math.max(Number(config.inPrice) || 0, eventInPriceUsd);
5253
- const bound = boundServe({ messages: request?.messages, budgetUsd: remainingUsd, inPrice, outPrice: config.outPrice, reqMax: Number(request?.max_tokens) });
5464
+ const outPrice = Math.max(Number(config.outPrice) || 0, eventOutPriceUsd);
5465
+ const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice, outPrice, reqMax: checked.safeRequest.max_tokens });
5254
5466
  if (bound.refuse) {
5255
- 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 });
5467
+ const error = bound.reason === "input" ? "input_too_large" : "output_unfunded";
5468
+ return send(res, 402, { error, detail: `estimated input (~${bound.estIn} tokens, $${bound.estInCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`, _bookingId: bookingId, remainingUsd });
5469
+ }
5470
+ const safeRequest = { ...checked.safeRequest, model: config.model, max_tokens: bound.maxTok };
5471
+ try {
5472
+ if (!served.claim(cacheKey, oldLegacyKey ?? cacheKey)) {
5473
+ return send(res, 409, { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId });
5474
+ }
5475
+ } catch (e) {
5476
+ return send(res, 503, { error: "redemption_unavailable", detail: `could not durably claim the paid draw: ${e.message}`, _bookingId: bookingId });
5256
5477
  }
5257
- const safeRequest = { ...request, max_tokens: bound.maxTok };
5258
5478
  let completion;
5259
5479
  try {
5260
5480
  completion = await upstream(safeRequest);
@@ -5271,7 +5491,11 @@ async function createRelayRuntime(config) {
5271
5491
  const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
5272
5492
  const usedUsd = (inTok * Number(paidEvent.inputPricePerMTokAtomic || 0) + outTok * Number(paidEvent.outputPricePerMTokAtomic || 0)) / 1e12;
5273
5493
  const payload = { ...completion, _bookingId: bookingId, remainingUsd: Math.max(0, Math.round((remainingUsd - usedUsd) * 1e6) / 1e6) };
5274
- served.set(cacheKey, payload);
5494
+ try {
5495
+ served.complete(cacheKey, payload);
5496
+ } catch (e) {
5497
+ (config.log ?? console).error?.(`mtok-relay: completion for ${cacheKey} could not be persisted (${e.message}); retries will remain pending`);
5498
+ }
5275
5499
  return send(res, 200, payload);
5276
5500
  });
5277
5501
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mtok-relay",
3
- "version": "0.1.9",
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.0",
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"