mtok-relay 0.2.2 → 0.2.5

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.
Files changed (2) hide show
  1. package/dist/mtok-relay.mjs +267 -91
  2. package/package.json +1 -1
@@ -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.55.10";
2160
+ var version = "2.56.8";
2161
2161
 
2162
2162
  // node_modules/viem/_esm/errors/base.js
2163
2163
  var errorConfig = {
@@ -2423,7 +2423,7 @@ function hexToBigInt(hex, opts = {}) {
2423
2423
  const value = BigInt(hex);
2424
2424
  if (!signed)
2425
2425
  return value;
2426
- const size2 = (hex.length - 2) / 2;
2426
+ const size2 = Math.ceil((hex.length - 2) / 2);
2427
2427
  const max = (1n << BigInt(size2) * 8n - 1n) - 1n;
2428
2428
  if (value <= max)
2429
2429
  return value;
@@ -4432,6 +4432,15 @@ var InvalidStructTypeError = class extends BaseError {
4432
4432
  });
4433
4433
  }
4434
4434
  };
4435
+ var InvalidTypedDataTypeError = class extends BaseError {
4436
+ constructor({ type }) {
4437
+ const canonicalType = type.replace(/^(u?int)/, "$&256");
4438
+ super(`Type "${type}" is not a valid EIP-712 type.`, {
4439
+ metaMessages: [`Use "${canonicalType}" instead.`],
4440
+ name: "InvalidTypedDataTypeError"
4441
+ });
4442
+ }
4443
+ };
4435
4444
 
4436
4445
  // node_modules/viem/_esm/utils/typedData.js
4437
4446
  function validateTypedData(parameters) {
@@ -4440,6 +4449,9 @@ function validateTypedData(parameters) {
4440
4449
  for (const param of struct) {
4441
4450
  const { name, type } = param;
4442
4451
  const value = data[name];
4452
+ const baseType = type.replace(/(\[[0-9]*\])+$/, "");
4453
+ if (baseType === "int" || baseType === "uint")
4454
+ throw new InvalidTypedDataTypeError({ type });
4443
4455
  const integerMatch = type.match(integerRegex);
4444
4456
  if (integerMatch && (typeof value === "number" || typeof value === "bigint")) {
4445
4457
  const [_type, base, size_] = integerMatch;
@@ -4650,6 +4662,7 @@ function privateKeyToAccount(privateKey, options = {}) {
4650
4662
  }
4651
4663
 
4652
4664
  // src/config.mjs
4665
+ import { isIP } from "node:net";
4653
4666
  var flag = (args, name) => {
4654
4667
  const i = args.indexOf(name);
4655
4668
  return i !== -1 ? args[i + 1] : null;
@@ -4670,6 +4683,20 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
4670
4683
  const redemptionFile = redemptionFlag === void 0 ? "./.mtok-redemption.jsonl" : redemptionFlag;
4671
4684
  const denylistRaw = flag(argv, "--payer-denylist") ?? env.RELAY_PAYER_DENYLIST ?? "";
4672
4685
  const payerDenylist = String(denylistRaw).split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
4686
+ const maxOutputRaw = flag(argv, "--max-output-tokens") ?? env.RELAY_MAX_OUTPUT_TOKENS;
4687
+ const maxOutputTokens = maxOutputRaw != null ? Number(maxOutputRaw) : void 0;
4688
+ if (maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 1)) {
4689
+ throw new Error("--max-output-tokens must be a positive integer");
4690
+ }
4691
+ const trustedProxies = String(env.RELAY_TRUSTED_PROXIES ?? "").split(",").map((value) => value.trim()).filter(Boolean);
4692
+ if (trustedProxies.some((value) => !isIP(value) || value.includes("%"))) throw new Error("RELAY_TRUSTED_PROXIES must contain exact IP addresses");
4693
+ const clientIpHeader = String(env.RELAY_CLIENT_IP_HEADER ?? "cf-connecting-ip").toLowerCase();
4694
+ if (!/^[a-z0-9-]+$/.test(clientIpHeader)) throw new Error("RELAY_CLIENT_IP_HEADER must be a header name");
4695
+ const maxConcurrentRequests = Number(env.RELAY_MAX_CONCURRENT_REQUESTS ?? 64);
4696
+ const upstreamTimeoutMs = Number(env.RELAY_UPSTREAM_TIMEOUT_MS ?? 12e4);
4697
+ for (const [name, value] of Object.entries({ RELAY_MAX_CONCURRENT_REQUESTS: maxConcurrentRequests, RELAY_UPSTREAM_TIMEOUT_MS: upstreamTimeoutMs })) {
4698
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) throw new Error(`${name} must be a positive finite integer`);
4699
+ }
4673
4700
  if (!offerId) throw new Error("--offer <id> is required");
4674
4701
  if (!model) throw new Error("--model <id> is required (the offer model you serve)");
4675
4702
  if (!upstream) throw new Error("--upstream <url> is required");
@@ -4708,23 +4735,73 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
4708
4735
  mtokApiKey,
4709
4736
  upstreamKey,
4710
4737
  settlementAddr,
4711
- payerDenylist
4738
+ payerDenylist,
4739
+ maxOutputTokens,
4740
+ trustedProxies,
4741
+ clientIpHeader,
4742
+ maxConcurrentRequests,
4743
+ upstreamTimeoutMs
4712
4744
  };
4713
4745
  }
4714
4746
 
4715
4747
  // src/http.mjs
4716
4748
  import http from "node:http";
4749
+ import { isIP as isIP2 } from "node:net";
4750
+
4751
+ // core/errors.js
4752
+ function apiError(status, code, message, details) {
4753
+ const err = new Error(message);
4754
+ err.status = status;
4755
+ err.code = code;
4756
+ if (details !== void 0) err.details = details;
4757
+ return err;
4758
+ }
4759
+
4760
+ // core/limiter.js
4761
+ function createLimiter({ windowMs = 6e4, max = 120, maxKeys = 1e4, now = () => Date.now() } = {}) {
4762
+ const windows = /* @__PURE__ */ new Map();
4763
+ return {
4764
+ check(key, scope = "api") {
4765
+ const id = `${scope}:${key}`;
4766
+ const t = now();
4767
+ let w = windows.get(id);
4768
+ if (!w || t - w.start >= windowMs) {
4769
+ w = { start: t, count: 0 };
4770
+ windows.delete(id);
4771
+ if (windows.size >= maxKeys) windows.delete(windows.keys().next().value);
4772
+ windows.set(id, w);
4773
+ }
4774
+ w.count += 1;
4775
+ if (w.count > max) {
4776
+ const retryInSeconds = Math.ceil((w.start + windowMs - t) / 1e3);
4777
+ throw apiError(429, "rate_limited", `Rate limit exceeded (${max}/${windowMs / 1e3}s). Retry in ~${retryInSeconds}s.`, { retryAfterSeconds: retryInSeconds });
4778
+ }
4779
+ },
4780
+ get size() {
4781
+ return windows.size;
4782
+ }
4783
+ };
4784
+ }
4785
+
4786
+ // src/http.mjs
4717
4787
  var MAX_BODY_BYTES = 256e3;
4718
- function readBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
4788
+ function readBody(req, { maxBytes = MAX_BODY_BYTES, timeoutMs = 1e4 } = {}) {
4719
4789
  return new Promise((resolve, reject) => {
4720
4790
  const chunks = [];
4721
4791
  let bytes = 0;
4722
4792
  let settled = false;
4793
+ const fail = (error) => {
4794
+ if (settled) return;
4795
+ settled = true;
4796
+ clearTimeout(timer);
4797
+ chunks.length = 0;
4798
+ reject(error);
4799
+ };
4800
+ const timer = setTimeout(() => fail(Object.assign(new Error("body_timeout"), { code: "body_timeout" })), timeoutMs);
4723
4801
  req.on("data", (d) => {
4724
4802
  bytes += d.length;
4725
4803
  if (bytes > maxBytes && !settled) {
4726
- settled = true;
4727
- reject(Object.assign(new Error("body_too_large"), { code: "body_too_large" }));
4804
+ fail(Object.assign(new Error("body_too_large"), { code: "body_too_large" }));
4728
4805
  req.resume();
4729
4806
  return;
4730
4807
  }
@@ -4734,6 +4811,7 @@ function readBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
4734
4811
  req.on("end", () => {
4735
4812
  if (settled) return;
4736
4813
  settled = true;
4814
+ clearTimeout(timer);
4737
4815
  const raw = Buffer.concat(chunks).toString("utf8");
4738
4816
  try {
4739
4817
  resolve(JSON.parse(raw || "{}"));
@@ -4741,54 +4819,82 @@ function readBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
4741
4819
  resolve({});
4742
4820
  }
4743
4821
  });
4744
- req.on("error", (e) => {
4745
- if (settled) return;
4746
- settled = true;
4747
- reject(e);
4748
- });
4822
+ req.on("error", fail);
4823
+ req.on("aborted", () => fail(new Error("body_aborted")));
4749
4824
  });
4750
4825
  }
4751
4826
  function send(res, status, body) {
4827
+ if (res.destroyed || res.writableEnded) return;
4752
4828
  const payload = JSON.stringify(body);
4753
4829
  res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
4754
4830
  res.end(payload);
4755
4831
  }
4832
+ function canonicalIp(value) {
4833
+ if (typeof value !== "string" || value.includes("%") || !isIP2(value)) return null;
4834
+ if (isIP2(value) === 4) return value;
4835
+ const canonical = new URL(`http://[${value}]/`).hostname.slice(1, -1);
4836
+ const mapped = /^::ffff:([\da-f]+):([\da-f]+)$/.exec(canonical);
4837
+ if (mapped) return mapped.slice(1).flatMap((word) => [parseInt(word, 16) >> 8, parseInt(word, 16) & 255]).join(".");
4838
+ return canonical;
4839
+ }
4840
+ function clientRateKey(req, trustedProxies = /* @__PURE__ */ new Set(), header = "cf-connecting-ip") {
4841
+ const peer = canonicalIp(req.socket.remoteAddress) ?? "unknown";
4842
+ const forwarded = trustedProxies.has(peer) ? canonicalIp(req.headers[header]) : null;
4843
+ const address = forwarded ?? peer;
4844
+ if (!address.includes(":")) return address;
4845
+ const [left, right] = address.split("::");
4846
+ const start = left ? left.split(":") : [];
4847
+ const end = right ? right.split(":") : [];
4848
+ const words = right === void 0 ? start : [...start, ...Array(8 - start.length - end.length).fill("0"), ...end];
4849
+ return words.slice(0, 4).map((word) => word.padStart(4, "0")).join(":") + "::/64";
4850
+ }
4756
4851
  function startRelayServer({ config, handleDraw }) {
4757
- const windows = /* @__PURE__ */ new Map();
4758
- const windowMs = 6e4;
4759
- const maxPerMinute = Number(config.maxRequestsPerMinute ?? 120);
4760
- const checkRate = (req) => {
4761
- if (!Number.isFinite(maxPerMinute)) return true;
4762
- const key = req.socket.remoteAddress || "unknown";
4763
- const now = Date.now();
4764
- let w = windows.get(key);
4765
- if (!w || now - w.start >= windowMs) {
4766
- w = { start: now, count: 0 };
4767
- windows.set(key, w);
4768
- if (windows.size > 1e4) {
4769
- for (const [k, v] of windows) if (now - v.start >= windowMs) windows.delete(k);
4770
- }
4771
- }
4772
- w.count += 1;
4773
- return w.count <= maxPerMinute;
4774
- };
4775
- const server = http.createServer(async (req, res) => {
4852
+ const callers = createLimiter({ max: config.maxRequestsPerMinute ?? 120 });
4853
+ const aggregate = createLimiter({ max: config.maxTotalRequestsPerMinute ?? 1200 });
4854
+ const trustedProxies = new Set((config.trustedProxies ?? []).map(canonicalIp).filter(Boolean));
4855
+ const maxConcurrent = config.maxConcurrentRequests ?? 64;
4856
+ let active = 0;
4857
+ const server = http.createServer({ headersTimeout: 1e4, requestTimeout: 15e3, connectionsCheckingInterval: 1e3 }, async (req, res) => {
4858
+ const refuse = (status, error) => {
4859
+ if (res.headersSent || res.destroyed) return res.destroy();
4860
+ res.setHeader("connection", "close");
4861
+ res.once("finish", () => req.destroy());
4862
+ send(res, status, { error });
4863
+ };
4776
4864
  if (req.method !== "POST" || req.url !== "/chunk") {
4777
- return send(res, 404, { error: "not found" });
4865
+ return refuse(404, "not found");
4778
4866
  }
4779
- if (!checkRate(req)) return send(res, 429, { error: "rate_limited" });
4780
- let body;
4781
4867
  try {
4782
- body = await readBody(req);
4783
- } catch (e) {
4784
- if (e?.code === "body_too_large") return send(res, 413, { error: "body_too_large" });
4785
- return send(res, 400, { error: "bad body" });
4868
+ callers.check(clientRateKey(req, trustedProxies, config.clientIpHeader ?? "cf-connecting-ip"));
4869
+ aggregate.check("all");
4870
+ } catch {
4871
+ res.setHeader("retry-after", "60");
4872
+ return refuse(429, "rate_limited");
4786
4873
  }
4787
- if (body.request == null) {
4788
- return send(res, 400, { error: "bad_request", detail: "need a DRAW (request); the legacy FUND lane is retired, pay per draw on-chain" });
4874
+ if (active >= maxConcurrent) return refuse(503, "relay_busy");
4875
+ active++;
4876
+ try {
4877
+ let body;
4878
+ try {
4879
+ body = await readBody(req, { timeoutMs: config.bodyTimeoutMs ?? 1e4 });
4880
+ } catch (e) {
4881
+ if (e?.code === "body_too_large") return refuse(413, "body_too_large");
4882
+ if (e?.code === "body_timeout") return refuse(408, "relay_timeout");
4883
+ return refuse(400, "bad body");
4884
+ }
4885
+ if (body?.request == null) {
4886
+ return send(res, 400, { error: "bad_request", detail: "need a DRAW (request); the legacy FUND lane is retired, pay per draw on-chain" });
4887
+ }
4888
+ await handleDraw(body, res);
4889
+ } catch (error) {
4890
+ (config.log ?? console).error?.(`mtok relay: request failed (${error.message})`);
4891
+ refuse(503, "relay_unavailable");
4892
+ } finally {
4893
+ active--;
4789
4894
  }
4790
- return handleDraw(body, res);
4791
4895
  });
4896
+ server.maxConnections = maxConcurrent * 2;
4897
+ server.maxRequestsPerSocket = 100;
4792
4898
  server.listen(config.port, () => {
4793
4899
  console.log(`mtok-relay: listening on port ${config.port} offer=${config.offerId} model=${config.model} upstream=${config.upstream} api=${config.apiBase} settlement=${config.settlementAddr}`);
4794
4900
  });
@@ -4929,7 +5035,17 @@ function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS
4929
5035
  if (map.has(key)) return false;
4930
5036
  if (!markClaimed(markerKey)) return false;
4931
5037
  const entry = { state: "pending", at: now() };
4932
- append(key, entry);
5038
+ try {
5039
+ append(key, entry);
5040
+ } catch (e) {
5041
+ if (durable) {
5042
+ try {
5043
+ fs.unlinkSync(markerFor(markerKey));
5044
+ } catch {
5045
+ }
5046
+ }
5047
+ throw e;
5048
+ }
4933
5049
  map.set(key, entry);
4934
5050
  return true;
4935
5051
  },
@@ -4949,15 +5065,6 @@ function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS
4949
5065
  };
4950
5066
  }
4951
5067
 
4952
- // core/errors.js
4953
- function apiError(status, code, message, details) {
4954
- const err = new Error(message);
4955
- err.status = status;
4956
- err.code = code;
4957
- if (details !== void 0) err.details = details;
4958
- return err;
4959
- }
4960
-
4961
5068
  // core/onchain.js
4962
5069
  var TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
4963
5070
  var DRAW_PAID_TOPIC = "0xb0243f80521d0dccd159389597aba96047e60ba5d7a9df12b67e5cb75230ac41";
@@ -5028,8 +5135,9 @@ function createOnchainVerifier({
5028
5135
  receiptRetries = 3,
5029
5136
  receiptRetryMs = 700,
5030
5137
  sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
5031
- nowMs = () => Date.now()
5138
+ nowMs = () => Date.now(),
5032
5139
  // injectable clock for the optional draw-age guard (verifyDrawPaid maxPaidAgeMs).
5140
+ rpcTimeoutMs = 5e3
5033
5141
  } = {}) {
5034
5142
  const urls = (rpcUrls?.length ? rpcUrls : String(rpcUrl || "").split(",")).map((s) => String(s).trim()).filter(Boolean);
5035
5143
  const configured = Boolean(urls.length && usdcAddress);
@@ -5038,7 +5146,8 @@ function createOnchainVerifier({
5038
5146
  const res = await fetchImpl(url, {
5039
5147
  method: "POST",
5040
5148
  headers: { "content-type": "application/json" },
5041
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
5149
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
5150
+ signal: AbortSignal.timeout(rpcTimeoutMs)
5042
5151
  });
5043
5152
  if (!res.ok) throw apiError(502, "rpc_error", `chain RPC returned ${res.status}`);
5044
5153
  const body = await res.json();
@@ -5063,13 +5172,16 @@ function createOnchainVerifier({
5063
5172
  async function assertChain() {
5064
5173
  if (!chainPinned) return true;
5065
5174
  if (chainOkUrls.size) return true;
5175
+ let timeout;
5066
5176
  for (const url of urls) {
5067
5177
  try {
5068
5178
  const hex = await rpcOn(url, "eth_chainId", []);
5069
5179
  if (typeof hex === "string" && parseInt(hex, 16) === expChain) chainOkUrls.add(url);
5070
- } catch {
5180
+ } catch (error) {
5181
+ if (error.name === "TimeoutError") timeout = error;
5071
5182
  }
5072
5183
  }
5184
+ if (!chainOkUrls.size && timeout) throw timeout;
5073
5185
  return chainOkUrls.size > 0;
5074
5186
  }
5075
5187
  async function fetchReceipt(txHash) {
@@ -5184,23 +5296,27 @@ function createOnchainVerifier({
5184
5296
  if (BigInt(event.sellerUsdAtomic) < BigInt(minSellerAtomic)) return { ok: false, reason: "amount_too_low" };
5185
5297
  const maxAge = Number(maxPaidAgeMs);
5186
5298
  if (Number.isFinite(maxAge) && maxAge > 0) {
5187
- const bn = matchedLog?.blockNumber;
5299
+ const bn = matchedLog?.blockNumber ?? got.receipt.blockNumber;
5188
5300
  let paidAtMs = null;
5189
5301
  if (bn != null) {
5190
- const blockTag = typeof bn === "string" && bn.startsWith("0x") ? bn : "0x" + BigInt(bn).toString(16);
5191
5302
  for (let i = 0; i <= receiptRetries; i++) {
5192
5303
  try {
5304
+ const blockTag = "0x" + BigInt(bn).toString(16);
5193
5305
  const block = await rpc("eth_getBlockByNumber", [blockTag, false]);
5194
5306
  if (block?.timestamp != null) {
5195
- paidAtMs = Number(BigInt(block.timestamp)) * 1e3;
5196
- break;
5307
+ const timestamp = Number(BigInt(block.timestamp)) * 1e3;
5308
+ if (Number.isSafeInteger(timestamp) && timestamp >= 0 && timestamp <= nowMs() + 6e4) {
5309
+ paidAtMs = timestamp;
5310
+ break;
5311
+ }
5197
5312
  }
5198
5313
  } catch {
5199
5314
  }
5200
5315
  if (i < receiptRetries) await sleepImpl(receiptRetryMs);
5201
5316
  }
5202
5317
  }
5203
- if (paidAtMs != null && nowMs() - paidAtMs > maxAge) return { ok: false, reason: "payment_too_old" };
5318
+ if (paidAtMs == null) return { ok: false, reason: "payment_age_unavailable" };
5319
+ if (nowMs() - paidAtMs > maxAge) return { ok: false, reason: "payment_too_old" };
5204
5320
  }
5205
5321
  const consumed = /* @__PURE__ */ new Set();
5206
5322
  let sellerTransfer = null;
@@ -5300,8 +5416,21 @@ function validateRequest(request, model, { legacy = false } = {}) {
5300
5416
  }
5301
5417
  };
5302
5418
  }
5419
+ function normalizeModelId(m) {
5420
+ return String(m ?? "").toLowerCase().split("/").pop().replace(/^@/, "");
5421
+ }
5422
+ function modelsCompatible(upstreamModel, offerModel) {
5423
+ const a = normalizeModelId(upstreamModel);
5424
+ const b = normalizeModelId(offerModel);
5425
+ if (!a || !b) return false;
5426
+ if (a === b) return true;
5427
+ const [longer, shorter] = a.length >= b.length ? [a, b] : [b, a];
5428
+ if (!longer.startsWith(shorter)) return false;
5429
+ const rest = longer.slice(shorter.length);
5430
+ return /^([-._]\d+)+$/.test(rest);
5431
+ }
5303
5432
  function enforceModelEcho(upstreamModel, offerModel) {
5304
- if (String(upstreamModel) !== String(offerModel))
5433
+ if (!modelsCompatible(upstreamModel, offerModel))
5305
5434
  throw new Error(`model mismatch: upstream ${upstreamModel} != offer ${offerModel}`);
5306
5435
  }
5307
5436
  function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
@@ -5322,7 +5451,8 @@ function estimateInputTokens(messages) {
5322
5451
  }
5323
5452
  return envelope + Math.ceil(bytes / BYTES_PER_TOKEN_EST);
5324
5453
  }
5325
- function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
5454
+ var DEFAULT_MAX_OUTPUT_TOKENS = 32768;
5455
+ function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
5326
5456
  const estIn = estimateInputTokens(messages);
5327
5457
  const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
5328
5458
  if (estInCostUsd >= budgetUsd) return { refuse: true, reason: "input", estIn, estInCostUsd };
@@ -5350,9 +5480,17 @@ function createServeCore({
5350
5480
  dripContractAddress,
5351
5481
  feeRecipient,
5352
5482
  feeBps,
5353
- screenPayer
5483
+ screenPayer,
5484
+ // #654: the output-token sanity ceiling for boundServe. The PAID budget already
5485
+ // bounds output (and metering is on real usage), so this is a defensive cap on a
5486
+ // single generation, not a money guard. It is a per-relay knob: the reference host
5487
+ // passes MTOK_MAX_OUTPUT_TOKENS / a config value; operators serving large-context
5488
+ // models raise it. Falls back to boundServe's own generous default when unset.
5489
+ maxOutputTokens
5354
5490
  }) {
5355
5491
  const serve = async (body) => {
5492
+ const currentFeeBps = typeof feeBps === "function" ? feeBps() : feeBps;
5493
+ const currentFeeRecipient = typeof feeRecipient === "function" ? feeRecipient() : feeRecipient;
5356
5494
  const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
5357
5495
  const hasRequestNonce = Object.hasOwn(body, "requestNonce");
5358
5496
  if (!bookingId) return { status: 400, body: { error: "bad_request", detail: "DRAW needs bookingId" } };
@@ -5373,6 +5511,17 @@ function createServeCore({
5373
5511
  return { status: 402, body: { error: "contract_mode_required", detail: "this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress" } };
5374
5512
  }
5375
5513
  if (!drawPaidTxHash) return { status: 402, body: { error: "draw_payment_required", detail: "contract mode requires drawPaidTxHash before upstream delivery" } };
5514
+ let storedKey = cacheKey;
5515
+ let redemptionState = await redemption.state(storedKey);
5516
+ if (!redemptionState && oldLegacyKey) {
5517
+ const oldLegacyState = await redemption.state(oldLegacyKey);
5518
+ if (oldLegacyState) {
5519
+ storedKey = oldLegacyKey;
5520
+ redemptionState = oldLegacyState;
5521
+ } else {
5522
+ redemptionState = await redemption.state(cacheKey);
5523
+ }
5524
+ }
5376
5525
  let paid;
5377
5526
  try {
5378
5527
  paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
@@ -5386,22 +5535,21 @@ function createServeCore({
5386
5535
  n,
5387
5536
  requestHash,
5388
5537
  sellerWallet,
5389
- feeRecipient,
5390
- // #580: refuse a payment older than the redemption window. The JSONL
5391
- // payload cache AND the claim markers are both aged out at boot (#600),
5392
- // so past retention this age bound is the sole replay defense (its
5393
- // skip-on-unreadable-block residual is named in redemption.mjs). An
5394
- // honest retry is seconds-to-minutes old, never days.
5395
- maxPaidAgeMs: redemption.retentionMs
5538
+ feeRecipient: currentFeeRecipient,
5539
+ // A known completion spends no new inference. New claims need a verified
5540
+ // age because both payload and claim markers expire after retention.
5541
+ maxPaidAgeMs: redemptionState === "complete" ? void 0 : redemption.retentionMs
5396
5542
  });
5397
5543
  } catch (e) {
5544
+ if (e.name === "TimeoutError") return { status: 503, body: { error: "relay_timeout", _bookingId: bookingId } };
5398
5545
  return { status: 402, body: { error: "payment_unverified", detail: e.message } };
5399
5546
  }
5547
+ if (paid?.reason === "payment_age_unavailable") return { status: 503, body: { error: "payment_age_unavailable", detail: "payment age could not be verified; retry this same paid draw", _bookingId: bookingId } };
5400
5548
  if (!paid?.ok) return { status: 402, body: { error: "payment_unverified", detail: paid?.reason || "unknown" } };
5401
5549
  const expectedFee = configuredFeeAtomic({
5402
5550
  sellerUsdAtomic: paid.event.sellerUsdAtomic,
5403
- feeAddress: feeRecipient,
5404
- feeBps
5551
+ feeAddress: currentFeeRecipient,
5552
+ feeBps: currentFeeBps
5405
5553
  });
5406
5554
  if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
5407
5555
  return { status: 402, body: { error: "payment_unverified", detail: "fee_amount_too_low" } };
@@ -5415,17 +5563,6 @@ function createServeCore({
5415
5563
  return { status: 403, body: { error: "payer_denied", detail: "payer screening failed: " + e.message } };
5416
5564
  }
5417
5565
  }
5418
- let storedKey = cacheKey;
5419
- let redemptionState = await redemption.state(storedKey);
5420
- if (!redemptionState && oldLegacyKey) {
5421
- const oldLegacyState = await redemption.state(oldLegacyKey);
5422
- if (oldLegacyState) {
5423
- storedKey = oldLegacyKey;
5424
- redemptionState = oldLegacyState;
5425
- } else {
5426
- redemptionState = await redemption.state(cacheKey);
5427
- }
5428
- }
5429
5566
  if (redemptionState === "complete") return { status: 200, body: await redemption.get(storedKey) };
5430
5567
  if (redemptionState === "pending") {
5431
5568
  return { status: 409, body: { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId } };
@@ -5439,7 +5576,7 @@ function createServeCore({
5439
5576
  const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
5440
5577
  const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
5441
5578
  const boundOutPrice = Math.max(Number(outPrice) || 0, eventOutPriceUsd);
5442
- const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice: boundInPrice, outPrice: boundOutPrice, reqMax: checked.safeRequest.max_tokens });
5579
+ const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice: boundInPrice, outPrice: boundOutPrice, reqMax: checked.safeRequest.max_tokens, ...Number(maxOutputTokens) > 0 ? { contextCeil: Math.floor(Number(maxOutputTokens)) } : {} });
5443
5580
  if (bound.refuse) {
5444
5581
  const error = bound.reason === "input" ? "input_too_large" : "output_unfunded";
5445
5582
  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 } };
@@ -5456,6 +5593,7 @@ function createServeCore({
5456
5593
  try {
5457
5594
  completion = await upstream(safeRequest);
5458
5595
  } catch (e) {
5596
+ if (e.name === "TimeoutError") return { status: 503, body: { error: "relay_timeout", _bookingId: bookingId } };
5459
5597
  return { status: 502, body: { error: "upstream_error", detail: e.message } };
5460
5598
  }
5461
5599
  try {
@@ -5463,6 +5601,7 @@ function createServeCore({
5463
5601
  } catch (e) {
5464
5602
  return { status: 502, body: { error: "model_mismatch", detail: e.message } };
5465
5603
  }
5604
+ if (completion && typeof completion === "object") completion.model = model;
5466
5605
  const usage = completion.usage ?? {};
5467
5606
  const inTok = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0;
5468
5607
  const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
@@ -5479,7 +5618,7 @@ function createServeCore({
5479
5618
  }
5480
5619
 
5481
5620
  // bridge/bridge.mjs
5482
- function httpUpstream({ baseUrl, key }) {
5621
+ function httpUpstream({ baseUrl, key, timeoutMs }) {
5483
5622
  const url = String(baseUrl || "").replace(/\/$/, "") + "/chat/completions";
5484
5623
  return async (payload) => {
5485
5624
  const res = await fetch(url, {
@@ -5488,7 +5627,8 @@ function httpUpstream({ baseUrl, key }) {
5488
5627
  "content-type": "application/json",
5489
5628
  ...key ? { authorization: `Bearer ${key}` } : {}
5490
5629
  },
5491
- body: JSON.stringify(payload)
5630
+ body: JSON.stringify(payload),
5631
+ ...timeoutMs == null ? {} : { signal: AbortSignal.timeout(timeoutMs) }
5492
5632
  });
5493
5633
  const text = await res.text();
5494
5634
  let json;
@@ -5520,11 +5660,29 @@ async function createRelayRuntime(config) {
5520
5660
  const served = createRedemptionStore({ file: config.redemptionFile, log: config.log });
5521
5661
  const drawLocks = /* @__PURE__ */ new Map();
5522
5662
  const platform = await fetchPlatformConfig(config);
5663
+ const bootFeeBps = platform.feeBps;
5664
+ const FEE_REFRESH_MS = 6e4;
5665
+ let lastConfigFetch = Date.now();
5666
+ const refreshPlatformFee = async () => {
5667
+ lastConfigFetch = Date.now();
5668
+ try {
5669
+ const fresh = await fetchPlatformConfig(config);
5670
+ platform.feeBps = fresh.feeBps;
5671
+ return true;
5672
+ } catch (e) {
5673
+ (config.log ?? console).error?.(`mtok relay: platform config refresh failed (${e.message}); keeping last-known fee rate`);
5674
+ return false;
5675
+ }
5676
+ };
5677
+ const refreshPlatformFeeIfStale = async () => {
5678
+ if (Date.now() - lastConfigFetch < FEE_REFRESH_MS) return;
5679
+ await refreshPlatformFee();
5680
+ };
5523
5681
  const verifier = createOnchainVerifier({ rpcUrls: rpcUrlsFor(platform.chainId, config.rpcFlag), usdcAddress: platform.usdcAddress, expectedChainId: platform.chainId });
5524
5682
  if (!verifier.configured) throw new Error("onchain verifier not configured (missing usdcAddress in /api/config)");
5525
5683
  const payerDenylist = new Set((config.payerDenylist ?? []).map((a) => String(a).trim().toLowerCase()).filter(Boolean));
5526
5684
  const screenPayer = typeof config.screenPayer === "function" ? config.screenPayer : null;
5527
- const upstream = httpUpstream({ baseUrl: config.upstream + "/v1", key: config.upstreamKey });
5685
+ const upstream = httpUpstream({ baseUrl: config.upstream + "/v1", key: config.upstreamKey, timeoutMs: config.upstreamTimeoutMs ?? 12e4 });
5528
5686
  const payerDenied = async (payer) => {
5529
5687
  if (!payer) return false;
5530
5688
  if (payerDenylist.has(payer)) return true;
@@ -5543,8 +5701,13 @@ async function createRelayRuntime(config) {
5543
5701
  sellerAgentId: config.sellerAgentId,
5544
5702
  sellerWallet: config.settlementAddr,
5545
5703
  dripContractAddress: platform.dripContractAddress,
5704
+ // #654: the fee floor is min(boot, current) so neither a fee increase nor a
5705
+ // decrease can over-demand and strand an honest already-paid draw; the recipient
5706
+ // stays pinned to the boot address (exact-match verify, see the note above).
5546
5707
  feeRecipient: platform.feeAddress,
5547
- feeBps: platform.feeBps,
5708
+ feeBps: () => Math.min(bootFeeBps, platform.feeBps),
5709
+ // #654: per-relay output sanity ceiling (unset => the shared generous default).
5710
+ maxOutputTokens: config.maxOutputTokens,
5548
5711
  screenPayer: payerDenied
5549
5712
  });
5550
5713
  const withBookingLock = async (bookingId, fn) => {
@@ -5565,14 +5728,27 @@ async function createRelayRuntime(config) {
5565
5728
  if (drawLocks.get(bookingId) === tail) drawLocks.delete(bookingId);
5566
5729
  }
5567
5730
  };
5568
- const handleDraw = (body, res) => withBookingLock(String(body?.bookingId ?? ""), async () => {
5569
- const out = await core.serve(body);
5570
- return send(res, out.status, out.body);
5571
- });
5731
+ let active = 0;
5732
+ const handleDraw = async (body, res) => {
5733
+ if (active >= (config.maxConcurrentRequests ?? 64)) return send(res, 503, { error: "relay_busy" });
5734
+ active++;
5735
+ try {
5736
+ return await withBookingLock(String(body?.bookingId ?? ""), async () => {
5737
+ await refreshPlatformFeeIfStale();
5738
+ let out = await core.serve(body);
5739
+ if (out.status === 402 && out.body?.detail === "fee_amount_too_low" && await refreshPlatformFee()) {
5740
+ out = await core.serve(body);
5741
+ }
5742
+ return send(res, out.status, out.body);
5743
+ });
5744
+ } finally {
5745
+ active--;
5746
+ }
5747
+ };
5572
5748
  return { handleDraw };
5573
5749
  }
5574
5750
  async function fetchPlatformConfig(config) {
5575
- const r = await fetch(config.apiBase + "/api/config");
5751
+ const r = await fetch(config.apiBase + "/api/config", { signal: AbortSignal.timeout(5e3) });
5576
5752
  if (!r.ok) throw new Error("config fetch failed: " + r.status);
5577
5753
  const body = await r.json();
5578
5754
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtok-relay",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
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": {