mtok-relay 0.2.3 → 0.2.6

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 +242 -116
  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.56.0";
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;
@@ -4662,6 +4662,7 @@ function privateKeyToAccount(privateKey, options = {}) {
4662
4662
  }
4663
4663
 
4664
4664
  // src/config.mjs
4665
+ import { isIP } from "node:net";
4665
4666
  var flag = (args, name) => {
4666
4667
  const i = args.indexOf(name);
4667
4668
  return i !== -1 ? args[i + 1] : null;
@@ -4687,6 +4688,15 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
4687
4688
  if (maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 1)) {
4688
4689
  throw new Error("--max-output-tokens must be a positive integer");
4689
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
+ }
4690
4700
  if (!offerId) throw new Error("--offer <id> is required");
4691
4701
  if (!model) throw new Error("--model <id> is required (the offer model you serve)");
4692
4702
  if (!upstream) throw new Error("--upstream <url> is required");
@@ -4726,23 +4736,72 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
4726
4736
  upstreamKey,
4727
4737
  settlementAddr,
4728
4738
  payerDenylist,
4729
- maxOutputTokens
4739
+ maxOutputTokens,
4740
+ trustedProxies,
4741
+ clientIpHeader,
4742
+ maxConcurrentRequests,
4743
+ upstreamTimeoutMs
4730
4744
  };
4731
4745
  }
4732
4746
 
4733
4747
  // src/http.mjs
4734
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
4735
4787
  var MAX_BODY_BYTES = 256e3;
4736
- function readBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
4788
+ function readBody(req, { maxBytes = MAX_BODY_BYTES, timeoutMs = 1e4 } = {}) {
4737
4789
  return new Promise((resolve, reject) => {
4738
4790
  const chunks = [];
4739
4791
  let bytes = 0;
4740
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);
4741
4801
  req.on("data", (d) => {
4742
4802
  bytes += d.length;
4743
4803
  if (bytes > maxBytes && !settled) {
4744
- settled = true;
4745
- 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" }));
4746
4805
  req.resume();
4747
4806
  return;
4748
4807
  }
@@ -4752,6 +4811,7 @@ function readBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
4752
4811
  req.on("end", () => {
4753
4812
  if (settled) return;
4754
4813
  settled = true;
4814
+ clearTimeout(timer);
4755
4815
  const raw = Buffer.concat(chunks).toString("utf8");
4756
4816
  try {
4757
4817
  resolve(JSON.parse(raw || "{}"));
@@ -4759,54 +4819,82 @@ function readBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
4759
4819
  resolve({});
4760
4820
  }
4761
4821
  });
4762
- req.on("error", (e) => {
4763
- if (settled) return;
4764
- settled = true;
4765
- reject(e);
4766
- });
4822
+ req.on("error", fail);
4823
+ req.on("aborted", () => fail(new Error("body_aborted")));
4767
4824
  });
4768
4825
  }
4769
4826
  function send(res, status, body) {
4827
+ if (res.destroyed || res.writableEnded) return;
4770
4828
  const payload = JSON.stringify(body);
4771
4829
  res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
4772
4830
  res.end(payload);
4773
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
+ }
4774
4851
  function startRelayServer({ config, handleDraw }) {
4775
- const windows = /* @__PURE__ */ new Map();
4776
- const windowMs = 6e4;
4777
- const maxPerMinute = Number(config.maxRequestsPerMinute ?? 120);
4778
- const checkRate = (req) => {
4779
- if (!Number.isFinite(maxPerMinute)) return true;
4780
- const key = req.socket.remoteAddress || "unknown";
4781
- const now = Date.now();
4782
- let w = windows.get(key);
4783
- if (!w || now - w.start >= windowMs) {
4784
- w = { start: now, count: 0 };
4785
- windows.set(key, w);
4786
- if (windows.size > 1e4) {
4787
- for (const [k, v] of windows) if (now - v.start >= windowMs) windows.delete(k);
4788
- }
4789
- }
4790
- w.count += 1;
4791
- return w.count <= maxPerMinute;
4792
- };
4793
- 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
+ };
4794
4864
  if (req.method !== "POST" || req.url !== "/chunk") {
4795
- return send(res, 404, { error: "not found" });
4865
+ return refuse(404, "not found");
4796
4866
  }
4797
- if (!checkRate(req)) return send(res, 429, { error: "rate_limited" });
4798
- let body;
4799
4867
  try {
4800
- body = await readBody(req);
4801
- } catch (e) {
4802
- if (e?.code === "body_too_large") return send(res, 413, { error: "body_too_large" });
4803
- 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");
4804
4873
  }
4805
- if (body.request == null) {
4806
- 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--;
4807
4894
  }
4808
- return handleDraw(body, res);
4809
4895
  });
4896
+ server.maxConnections = maxConcurrent * 2;
4897
+ server.maxRequestsPerSocket = 100;
4810
4898
  server.listen(config.port, () => {
4811
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}`);
4812
4900
  });
@@ -4977,15 +5065,6 @@ function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS
4977
5065
  };
4978
5066
  }
4979
5067
 
4980
- // core/errors.js
4981
- function apiError(status, code, message, details) {
4982
- const err = new Error(message);
4983
- err.status = status;
4984
- err.code = code;
4985
- if (details !== void 0) err.details = details;
4986
- return err;
4987
- }
4988
-
4989
5068
  // core/onchain.js
4990
5069
  var TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
4991
5070
  var DRAW_PAID_TOPIC = "0xb0243f80521d0dccd159389597aba96047e60ba5d7a9df12b67e5cb75230ac41";
@@ -5056,8 +5135,9 @@ function createOnchainVerifier({
5056
5135
  receiptRetries = 3,
5057
5136
  receiptRetryMs = 700,
5058
5137
  sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
5059
- nowMs = () => Date.now()
5138
+ nowMs = () => Date.now(),
5060
5139
  // injectable clock for the optional draw-age guard (verifyDrawPaid maxPaidAgeMs).
5140
+ rpcTimeoutMs = 5e3
5061
5141
  } = {}) {
5062
5142
  const urls = (rpcUrls?.length ? rpcUrls : String(rpcUrl || "").split(",")).map((s) => String(s).trim()).filter(Boolean);
5063
5143
  const configured = Boolean(urls.length && usdcAddress);
@@ -5066,7 +5146,8 @@ function createOnchainVerifier({
5066
5146
  const res = await fetchImpl(url, {
5067
5147
  method: "POST",
5068
5148
  headers: { "content-type": "application/json" },
5069
- 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)
5070
5151
  });
5071
5152
  if (!res.ok) throw apiError(502, "rpc_error", `chain RPC returned ${res.status}`);
5072
5153
  const body = await res.json();
@@ -5091,13 +5172,16 @@ function createOnchainVerifier({
5091
5172
  async function assertChain() {
5092
5173
  if (!chainPinned) return true;
5093
5174
  if (chainOkUrls.size) return true;
5175
+ let timeout;
5094
5176
  for (const url of urls) {
5095
5177
  try {
5096
5178
  const hex = await rpcOn(url, "eth_chainId", []);
5097
5179
  if (typeof hex === "string" && parseInt(hex, 16) === expChain) chainOkUrls.add(url);
5098
- } catch {
5180
+ } catch (error) {
5181
+ if (error.name === "TimeoutError") timeout = error;
5099
5182
  }
5100
5183
  }
5184
+ if (!chainOkUrls.size && timeout) throw timeout;
5101
5185
  return chainOkUrls.size > 0;
5102
5186
  }
5103
5187
  async function fetchReceipt(txHash) {
@@ -5211,24 +5295,28 @@ function createOnchainVerifier({
5211
5295
  if (requestHash != null && lc(event.requestHash) !== lc(requestHash)) return { ok: false, reason: "request_hash_mismatch" };
5212
5296
  if (BigInt(event.sellerUsdAtomic) < BigInt(minSellerAtomic)) return { ok: false, reason: "amount_too_low" };
5213
5297
  const maxAge = Number(maxPaidAgeMs);
5298
+ let paidAtMs = null;
5214
5299
  if (Number.isFinite(maxAge) && maxAge > 0) {
5215
- const bn = matchedLog?.blockNumber;
5216
- let paidAtMs = null;
5300
+ const bn = matchedLog?.blockNumber ?? got.receipt.blockNumber;
5217
5301
  if (bn != null) {
5218
- const blockTag = typeof bn === "string" && bn.startsWith("0x") ? bn : "0x" + BigInt(bn).toString(16);
5219
5302
  for (let i = 0; i <= receiptRetries; i++) {
5220
5303
  try {
5304
+ const blockTag = "0x" + BigInt(bn).toString(16);
5221
5305
  const block = await rpc("eth_getBlockByNumber", [blockTag, false]);
5222
5306
  if (block?.timestamp != null) {
5223
- paidAtMs = Number(BigInt(block.timestamp)) * 1e3;
5224
- 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
+ }
5225
5312
  }
5226
5313
  } catch {
5227
5314
  }
5228
5315
  if (i < receiptRetries) await sleepImpl(receiptRetryMs);
5229
5316
  }
5230
5317
  }
5231
- 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" };
5232
5320
  }
5233
5321
  const consumed = /* @__PURE__ */ new Set();
5234
5322
  let sellerTransfer = null;
@@ -5242,11 +5330,37 @@ function createOnchainVerifier({
5242
5330
  feeTransfer = findUsdcTransfer(got.receipt, { to: feeRecipient, minAtomic: BigInt(event.feeUsdAtomic), from: event.buyer, consumed });
5243
5331
  if (!feeTransfer.ok) return { ok: false, reason: "fee_transfer_" + feeTransfer.reason };
5244
5332
  }
5245
- return { ok: true, event, from: sellerTransfer?.from ?? feeTransfer?.from ?? null };
5333
+ return { ok: true, event, paidAtMs, from: sellerTransfer?.from ?? feeTransfer?.from ?? null };
5246
5334
  }
5247
5335
  };
5248
5336
  }
5249
5337
 
5338
+ // core/fee-policy.js
5339
+ function normalizeFeeSchedule(schedule) {
5340
+ if (!Array.isArray(schedule) || !schedule.length || schedule.length > 64) throw new TypeError("fee schedule must contain 1..64 activation records");
5341
+ let previous = -1;
5342
+ return schedule.map(({ effectiveAtMs, feeBps }, index) => {
5343
+ if (!Number.isSafeInteger(effectiveAtMs) || effectiveAtMs <= previous || index === 0 && effectiveAtMs !== 0) {
5344
+ throw new TypeError("fee activations must start at 0 and increase in integer milliseconds");
5345
+ }
5346
+ if (!Number.isSafeInteger(feeBps) || feeBps < 0 || feeBps > 1e4) throw new TypeError("feeBps must be an integer from 0 to 10000");
5347
+ previous = effectiveAtMs;
5348
+ return { effectiveAtMs, feeBps };
5349
+ });
5350
+ }
5351
+ function feeBpsAt(schedule, timeMs) {
5352
+ if (!Number.isSafeInteger(timeMs) || timeMs < 0) throw new TypeError("verified payment time is required for fee policy");
5353
+ return schedule.findLast((entry) => entry.effectiveAtMs <= timeMs).feeBps;
5354
+ }
5355
+ function paymentFeeBpsAt(schedule, paidAtMs) {
5356
+ const start = Math.max(0, paidAtMs - 6e4);
5357
+ let minimum = Math.min(feeBpsAt(schedule, paidAtMs), feeBpsAt(schedule, start));
5358
+ for (const entry of schedule) {
5359
+ if (entry.effectiveAtMs > start && entry.effectiveAtMs <= paidAtMs) minimum = Math.min(minimum, entry.feeBps);
5360
+ }
5361
+ return minimum;
5362
+ }
5363
+
5250
5364
  // bridge/serve-core.mjs
5251
5365
  var BALANCE_EPSILON = 1e-6;
5252
5366
  var REQUEST_NONCE_RE = /^0x[0-9a-fA-F]{32}$/;
@@ -5401,7 +5515,6 @@ function createServeCore({
5401
5515
  maxOutputTokens
5402
5516
  }) {
5403
5517
  const serve = async (body) => {
5404
- const currentFeeBps = typeof feeBps === "function" ? feeBps() : feeBps;
5405
5518
  const currentFeeRecipient = typeof feeRecipient === "function" ? feeRecipient() : feeRecipient;
5406
5519
  const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
5407
5520
  const hasRequestNonce = Object.hasOwn(body, "requestNonce");
@@ -5423,6 +5536,17 @@ function createServeCore({
5423
5536
  return { status: 402, body: { error: "contract_mode_required", detail: "this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress" } };
5424
5537
  }
5425
5538
  if (!drawPaidTxHash) return { status: 402, body: { error: "draw_payment_required", detail: "contract mode requires drawPaidTxHash before upstream delivery" } };
5539
+ let storedKey = cacheKey;
5540
+ let redemptionState = await redemption.state(storedKey);
5541
+ if (!redemptionState && oldLegacyKey) {
5542
+ const oldLegacyState = await redemption.state(oldLegacyKey);
5543
+ if (oldLegacyState) {
5544
+ storedKey = oldLegacyKey;
5545
+ redemptionState = oldLegacyState;
5546
+ } else {
5547
+ redemptionState = await redemption.state(cacheKey);
5548
+ }
5549
+ }
5426
5550
  let paid;
5427
5551
  try {
5428
5552
  paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
@@ -5437,25 +5561,16 @@ function createServeCore({
5437
5561
  requestHash,
5438
5562
  sellerWallet,
5439
5563
  feeRecipient: currentFeeRecipient,
5440
- // #580: refuse a payment older than the redemption window. The JSONL
5441
- // payload cache AND the claim markers are both aged out at boot (#600),
5442
- // so past retention this age bound is the sole replay defense (its
5443
- // skip-on-unreadable-block residual is named in redemption.mjs). An
5444
- // honest retry is seconds-to-minutes old, never days.
5445
- maxPaidAgeMs: redemption.retentionMs
5564
+ // A known claim spends no new inference. New claims need a verified
5565
+ // age because both payload and claim markers expire after retention.
5566
+ maxPaidAgeMs: redemptionState === "complete" || redemptionState === "pending" ? void 0 : redemption.retentionMs
5446
5567
  });
5447
5568
  } catch (e) {
5569
+ if (e.name === "TimeoutError") return { status: 503, body: { error: "relay_timeout", _bookingId: bookingId } };
5448
5570
  return { status: 402, body: { error: "payment_unverified", detail: e.message } };
5449
5571
  }
5572
+ 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 } };
5450
5573
  if (!paid?.ok) return { status: 402, body: { error: "payment_unverified", detail: paid?.reason || "unknown" } };
5451
- const expectedFee = configuredFeeAtomic({
5452
- sellerUsdAtomic: paid.event.sellerUsdAtomic,
5453
- feeAddress: currentFeeRecipient,
5454
- feeBps: currentFeeBps
5455
- });
5456
- if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
5457
- return { status: 402, body: { error: "payment_unverified", detail: "fee_amount_too_low" } };
5458
- }
5459
5574
  if (screenPayer) {
5460
5575
  try {
5461
5576
  if (await screenPayer(String(paid.from || "").toLowerCase())) {
@@ -5465,21 +5580,21 @@ function createServeCore({
5465
5580
  return { status: 403, body: { error: "payer_denied", detail: "payer screening failed: " + e.message } };
5466
5581
  }
5467
5582
  }
5468
- let storedKey = cacheKey;
5469
- let redemptionState = await redemption.state(storedKey);
5470
- if (!redemptionState && oldLegacyKey) {
5471
- const oldLegacyState = await redemption.state(oldLegacyKey);
5472
- if (oldLegacyState) {
5473
- storedKey = oldLegacyKey;
5474
- redemptionState = oldLegacyState;
5475
- } else {
5476
- redemptionState = await redemption.state(cacheKey);
5477
- }
5478
- }
5479
5583
  if (redemptionState === "complete") return { status: 200, body: await redemption.get(storedKey) };
5480
5584
  if (redemptionState === "pending") {
5481
5585
  return { status: 409, body: { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId } };
5482
5586
  }
5587
+ let currentFeeBps;
5588
+ try {
5589
+ currentFeeBps = typeof feeBps === "function" ? await feeBps(paid) : feeBps ?? 0;
5590
+ if (!Number.isSafeInteger(currentFeeBps) || currentFeeBps < 0 || currentFeeBps > 1e4) throw new TypeError("invalid fee rate");
5591
+ } catch {
5592
+ return { status: 503, body: { error: "fee_policy_unavailable", detail: "fee policy could not be verified; retry this same paid draw", _bookingId: bookingId } };
5593
+ }
5594
+ const expectedFee = configuredFeeAtomic({ sellerUsdAtomic: paid.event.sellerUsdAtomic, feeAddress: currentFeeRecipient, feeBps: currentFeeBps });
5595
+ if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
5596
+ return { status: 402, body: { error: "payment_unverified", detail: "fee_amount_too_low" } };
5597
+ }
5483
5598
  const paidEvent = paid.event;
5484
5599
  const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
5485
5600
  if (remainingUsd < BALANCE_EPSILON) {
@@ -5506,6 +5621,7 @@ function createServeCore({
5506
5621
  try {
5507
5622
  completion = await upstream(safeRequest);
5508
5623
  } catch (e) {
5624
+ if (e.name === "TimeoutError") return { status: 503, body: { error: "relay_timeout", _bookingId: bookingId } };
5509
5625
  return { status: 502, body: { error: "upstream_error", detail: e.message } };
5510
5626
  }
5511
5627
  try {
@@ -5530,7 +5646,7 @@ function createServeCore({
5530
5646
  }
5531
5647
 
5532
5648
  // bridge/bridge.mjs
5533
- function httpUpstream({ baseUrl, key }) {
5649
+ function httpUpstream({ baseUrl, key, timeoutMs }) {
5534
5650
  const url = String(baseUrl || "").replace(/\/$/, "") + "/chat/completions";
5535
5651
  return async (payload) => {
5536
5652
  const res = await fetch(url, {
@@ -5539,7 +5655,8 @@ function httpUpstream({ baseUrl, key }) {
5539
5655
  "content-type": "application/json",
5540
5656
  ...key ? { authorization: `Bearer ${key}` } : {}
5541
5657
  },
5542
- body: JSON.stringify(payload)
5658
+ body: JSON.stringify(payload),
5659
+ ...timeoutMs == null ? {} : { signal: AbortSignal.timeout(timeoutMs) }
5543
5660
  });
5544
5661
  const text = await res.text();
5545
5662
  let json;
@@ -5571,29 +5688,30 @@ async function createRelayRuntime(config) {
5571
5688
  const served = createRedemptionStore({ file: config.redemptionFile, log: config.log });
5572
5689
  const drawLocks = /* @__PURE__ */ new Map();
5573
5690
  const platform = await fetchPlatformConfig(config);
5574
- const bootFeeBps = platform.feeBps;
5575
5691
  const FEE_REFRESH_MS = 6e4;
5576
5692
  let lastConfigFetch = Date.now();
5577
- const refreshPlatformFee = async () => {
5578
- lastConfigFetch = Date.now();
5579
- try {
5580
- const fresh = await fetchPlatformConfig(config);
5581
- platform.feeBps = fresh.feeBps;
5582
- return true;
5583
- } catch (e) {
5584
- (config.log ?? console).error?.(`mtok relay: platform config refresh failed (${e.message}); keeping last-known fee rate`);
5585
- return false;
5586
- }
5587
- };
5588
- const refreshPlatformFeeIfStale = async () => {
5589
- if (Date.now() - lastConfigFetch < FEE_REFRESH_MS) return;
5590
- await refreshPlatformFee();
5693
+ let refreshing;
5694
+ const refreshPlatformFee = () => {
5695
+ if (!refreshing) refreshing = (async () => {
5696
+ try {
5697
+ const fresh = await fetchPlatformConfig(config);
5698
+ platform.feeSchedule = fresh.feeSchedule;
5699
+ lastConfigFetch = Date.now();
5700
+ return true;
5701
+ } catch (e) {
5702
+ (config.log ?? console).error?.(`mtok relay: platform config refresh failed (${e.message}); new claims require current policy`);
5703
+ return false;
5704
+ }
5705
+ })().finally(() => {
5706
+ refreshing = null;
5707
+ });
5708
+ return refreshing;
5591
5709
  };
5592
5710
  const verifier = createOnchainVerifier({ rpcUrls: rpcUrlsFor(platform.chainId, config.rpcFlag), usdcAddress: platform.usdcAddress, expectedChainId: platform.chainId });
5593
5711
  if (!verifier.configured) throw new Error("onchain verifier not configured (missing usdcAddress in /api/config)");
5594
5712
  const payerDenylist = new Set((config.payerDenylist ?? []).map((a) => String(a).trim().toLowerCase()).filter(Boolean));
5595
5713
  const screenPayer = typeof config.screenPayer === "function" ? config.screenPayer : null;
5596
- const upstream = httpUpstream({ baseUrl: config.upstream + "/v1", key: config.upstreamKey });
5714
+ const upstream = httpUpstream({ baseUrl: config.upstream + "/v1", key: config.upstreamKey, timeoutMs: config.upstreamTimeoutMs ?? 12e4 });
5597
5715
  const payerDenied = async (payer) => {
5598
5716
  if (!payer) return false;
5599
5717
  if (payerDenylist.has(payer)) return true;
@@ -5612,11 +5730,11 @@ async function createRelayRuntime(config) {
5612
5730
  sellerAgentId: config.sellerAgentId,
5613
5731
  sellerWallet: config.settlementAddr,
5614
5732
  dripContractAddress: platform.dripContractAddress,
5615
- // #654: the fee floor is min(boot, current) so neither a fee increase nor a
5616
- // decrease can over-demand and strand an honest already-paid draw; the recipient
5617
- // stays pinned to the boot address (exact-match verify, see the note above).
5618
5733
  feeRecipient: platform.feeAddress,
5619
- feeBps: () => Math.min(bootFeeBps, platform.feeBps),
5734
+ feeBps: async ({ paidAtMs }) => {
5735
+ if (Date.now() - lastConfigFetch >= FEE_REFRESH_MS && !await refreshPlatformFee()) throw new Error("fee policy refresh failed");
5736
+ return paymentFeeBpsAt(platform.feeSchedule, paidAtMs);
5737
+ },
5620
5738
  // #654: per-relay output sanity ceiling (unset => the shared generous default).
5621
5739
  maxOutputTokens: config.maxOutputTokens,
5622
5740
  screenPayer: payerDenied
@@ -5639,23 +5757,31 @@ async function createRelayRuntime(config) {
5639
5757
  if (drawLocks.get(bookingId) === tail) drawLocks.delete(bookingId);
5640
5758
  }
5641
5759
  };
5642
- const handleDraw = (body, res) => withBookingLock(String(body?.bookingId ?? ""), async () => {
5643
- await refreshPlatformFeeIfStale();
5644
- let out = await core.serve(body);
5645
- if (out.status === 402 && out.body?.detail === "fee_amount_too_low" && await refreshPlatformFee()) {
5646
- out = await core.serve(body);
5760
+ let active = 0;
5761
+ const handleDraw = async (body, res) => {
5762
+ if (active >= (config.maxConcurrentRequests ?? 64)) return send(res, 503, { error: "relay_busy" });
5763
+ active++;
5764
+ try {
5765
+ return await withBookingLock(String(body?.bookingId ?? ""), async () => {
5766
+ let out = await core.serve(body);
5767
+ if (out.status === 402 && out.body?.detail === "fee_amount_too_low") {
5768
+ out = await refreshPlatformFee() ? await core.serve(body) : { status: 503, body: { error: "fee_policy_unavailable", _bookingId: body.bookingId } };
5769
+ }
5770
+ return send(res, out.status, out.body);
5771
+ });
5772
+ } finally {
5773
+ active--;
5647
5774
  }
5648
- return send(res, out.status, out.body);
5649
- });
5775
+ };
5650
5776
  return { handleDraw };
5651
5777
  }
5652
5778
  async function fetchPlatformConfig(config) {
5653
- const r = await fetch(config.apiBase + "/api/config");
5779
+ const r = await fetch(config.apiBase + "/api/config", { signal: AbortSignal.timeout(5e3) });
5654
5780
  if (!r.ok) throw new Error("config fetch failed: " + r.status);
5655
5781
  const body = await r.json();
5656
5782
  return {
5657
5783
  feeAddress: body.feeAddress,
5658
- feeBps: body.feeBps,
5784
+ feeSchedule: normalizeFeeSchedule(body.feeSchedule ?? [{ effectiveAtMs: 0, feeBps: body.feeBps ?? (body.feeAddress ? void 0 : 0) }]),
5659
5785
  dustThresholdUsd: Number(body.dustThresholdUsd) || 1e-3,
5660
5786
  chainId: Number(body.chainId ?? 8453),
5661
5787
  usdcAddress: body.usdcAddress,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtok-relay",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
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": {