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.
- package/dist/mtok-relay.mjs +242 -116
- package/package.json +1 -1
package/dist/mtok-relay.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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",
|
|
4763
|
-
|
|
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
|
|
4776
|
-
const
|
|
4777
|
-
const
|
|
4778
|
-
const
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
const
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
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
|
|
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
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
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 (
|
|
4806
|
-
|
|
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
|
-
|
|
5224
|
-
|
|
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
|
|
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
|
-
//
|
|
5441
|
-
//
|
|
5442
|
-
|
|
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
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
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: () =>
|
|
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
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
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
|
-
|
|
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