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.
- package/dist/mtok-relay.mjs +267 -91
- 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.
|
|
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
|
-
|
|
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",
|
|
4745
|
-
|
|
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
|
|
4758
|
-
const
|
|
4759
|
-
const
|
|
4760
|
-
const
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
const
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
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
|
|
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
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
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 (
|
|
4788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5196
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
5391
|
-
//
|
|
5392
|
-
|
|
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:
|
|
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
|
-
|
|
5569
|
-
|
|
5570
|
-
return send(res,
|
|
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