import test from 'node:test';
import assert from 'node:assert/strict';
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
import { validateDiscoveryExtension } from '@x402/extensions/bazaar';
import { makeApp, BASE, PAY_TO, mockVenues } from './helpers.js';
import { headAsGet } from '../src/head.js';
import { handle as stdioHandle, TOOLS as STDIO_TOOLS } from '../src/mcp-stdio.js';
import { toOrder } from '../src/lite.js';
import { _clearCache } from '../src/engine.js';
import { VERSION_STR } from '../src/version.js';
import fs from 'node:fs';
const decode = (h) => JSON.parse(Buffer.from(h, 'base64').toString('utf8'));
const v2pay = (fetchApp) => wrapFetchWithPaymentFromConfig(fetchApp, { schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(privateKeyToAccount(generatePrivateKey())) }] });
const json = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const CLEAN = 'coin=BTC&side=long&size=0.001&leverage=10';
const BAD = 'coin=ETH&side=long&size=0.0012345&price=3000.123&leverage=60';
const ROUTES = [['lite', '3000', CLEAN], ['full', '10000', CLEAN]];
const NFA = /Not financial advice; the exchange decides whether an order is accepted\./;
const mcp = (fetchApp, body) => fetchApp(`${BASE}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, ...body }) });
test.beforeEach(() => _clearCache());
test('unpaid: every paid route answers 402 on GET, POST, empty POST and bare probes (v2 header + valid discovery ext, v1 body); no upstream call', async () => {
const { fetchApp, net } = await makeApp();
for (const [tier, amount, qs] of ROUTES) {
for (const [u, init] of [[`${BASE}/v1/${tier}?${qs}`], [`${BASE}/v1/${tier}`], [`${BASE}/v1/${tier}`, json(Object.fromEntries(new URLSearchParams(qs)))], [`${BASE}/v1/${tier}`, { method: 'POST' }], [`${BASE}/v1/${tier}`, json({})]]) {
const r = await fetchApp(u, init);
assert.equal(r.status, 402, `${init?.method || 'GET'} ${u}`);
const req = decode(r.headers.get('PAYMENT-REQUIRED')); const a = req.accepts[0];
assert.deepEqual([req.x402Version, a.scheme, a.network, a.amount, a.payTo, a.asset], [2, 'exact', 'eip155:8453', amount, PAY_TO, '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913']);
assert.equal(req.resource.url, `${BASE}/v1/${tier}`);
assert.ok(validateDiscoveryExtension(req.extensions.bazaar).valid, 'discovery declaration valid');
assert.ok(r.headers.get('PAYMENT-REQUIRED').length <= 8192, 'header size');
assert.match(req.resource.description, NFA);
const b = await r.json(); assert.equal(b.x402Version, 1); assert.equal(b.accepts[0].maxAmountRequired, amount); assert.equal(b.accepts[0].network, 'base');
}
}
assert.equal(net.calls.length, 0);
});
test('HEAD = 402 + PAYMENT-REQUIRED with no body and never settles', async () => {
const { app, facilitator } = await makeApp();
const h = headAsGet((req) => app.fetch(req));
for (const [tier, amount, qs] of ROUTES) {
for (const u of [`${BASE}/v1/${tier}?${qs}`, `${BASE}/v1/${tier}`]) {
const r = await h(new Request(u, { method: 'HEAD', headers: { 'payment-signature': 'junk', 'x-payment': 'junk' } }));
assert.equal(r.status, 402); assert.equal(decode(r.headers.get('payment-required')).accepts[0].amount, amount); assert.equal(await r.text(), '');
}
}
assert.equal(facilitator.verified.length, 0);
});
test('bad input: 400 before payment (never a 402, never charged, nothing fetched), with the disclaimer', async () => {
const { fetchApp, facilitator, net } = await makeApp();
const bads = ['coin=BTC', 'side=long&size=1', 'coin=BTC&side=up&size=1', 'coin=BTC&side=long', 'coin=BTC&side=long&size=-1', 'coin=B%20TC&side=long&size=1', 'coin=xyz:META&side=long&size=1',
'coin=BTC&side=long&size=abc', 'coin=BTC&side=long&size=1&leverage=0', 'coin=BTC&side=long&size=1&leverage=5000', 'coin=BTC&side=long&size=1&margin=portfolio', 'coin=BTC&side=long&size=1&reduceOnly=maybe', `coin=${'A'.repeat(30)}&side=long&size=1`];
for (const tier of ['lite', 'full', 'preview']) for (const qs of bads) {
const r = await fetchApp(`${BASE}/v1/${tier}?${qs}`); assert.equal(r.status, 400, `${tier} ${qs}`);
assert.match((await r.json()).disclaimer, NFA);
}
assert.equal((await fetchApp(`${BASE}/v1/full`, json({ coin: 5, side: 'long', size: 1 }))).status, 400);
assert.equal((await fetchApp(`${BASE}/v1/full`, json({ coin: 'BTC', side: 'long', size: { x: 1 } }))).status, 400);
assert.equal((await fetchApp(`${BASE}/v1/full`, json({ foo: 1 }))).status, 400);
assert.equal((await fetchApp(`${BASE}/v1/full`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' })).status, 400);
assert.equal(facilitator.verified.length, 0); assert.equal(net.calls.length, 0);
});
test('paid v2 lite (clean order): OK, rules, no market facts, settled once at $0.003', async () => {
const { fetchApp, facilitator } = await makeApp();
const r = await v2pay(fetchApp)(`${BASE}/v1/lite?${CLEAN}`);
assert.equal(r.status, 200); const j = await r.json();
assert.equal(j.tier, 'lite'); assert.equal(j.coin, 'BTC'); assert.equal(j.verdict, 'OK'); assert.deepEqual(j.issues, []); assert.equal(j.confidence, 'high'); assert.deepEqual(j.sources_failed, []);
assert.equal(j.rules.szDecimals, 5); assert.equal(j.liquidation, undefined); assert.equal(j.execution, undefined); assert.equal(j.funding, undefined);
assert.match(j.disclaimer, NFA);
assert.equal(facilitator.settled.length, 1); assert.equal(facilitator.settled[0].amount, '3000'); assert.ok(r.headers.get('payment-response'));
});
test('paid v2 lite (bad order) and full (POST body): rule rejects with fix values; full adds liquidation, impact, funding, market', async () => {
const { fetchApp, facilitator } = await makeApp();
const r = await v2pay(fetchApp)(`${BASE}/v1/lite?${BAD}`);
assert.equal(r.status, 200); const j = await r.json();
assert.equal(j.verdict, 'REJECT_LIKELY');
const by = Object.fromEntries(j.issues.map((i) => [i.code, i]));
assert.ok(by.min_notional && by.size_decimals && by.price_tick && by.leverage, JSON.stringify(j.issues));
assert.equal(by.size_decimals.fix, 0.0012); assert.equal(by.price_tick.fix, 3000.1); assert.equal(by.leverage.fix, 25);
const f = await v2pay(fetchApp)(`${BASE}/v1/full`, json({ coin: 'btc', side: 'buy', size: '0.001', leverage: 10 }));
assert.equal(f.status, 200); const fj = await f.json();
assert.equal(fj.tier, 'full'); assert.equal(fj.verdict, 'OK'); assert.equal(fj.coin, 'BTC');
assert.ok(fj.liquidation.estimate_isolated < 80000 && fj.liquidation.distance_pct > 8); assert.ok(fj.execution.est_impact_bps >= 0); assert.equal(fj.funding.direction, 'you pay'); assert.equal(fj.market.mark, 80000);
assert.match(fj.disclaimer, NFA);
assert.deepEqual(facilitator.settled.map((x) => x.amount), ['3000', '10000']);
});
test('delisted + OI cap, isolated-only, reduce-only exemptions', async () => {
const { fetchApp } = await makeApp();
const d = await (await v2pay(fetchApp)(`${BASE}/v1/lite?coin=FTM&side=long¬ional=100`)).json();
assert.deepEqual(d.issues.map((i) => i.code).sort(), ['delisted', 'oi_cap']);
const iso = await (await v2pay(fetchApp)(`${BASE}/v1/lite?coin=ISO&side=short¬ional=50&margin=cross`)).json();
assert.ok(iso.issues.some((i) => i.code === 'isolated_only'));
const ro = await (await v2pay(fetchApp)(`${BASE}/v1/lite?coin=CAPD&side=short¬ional=5&reduceOnly=true`)).json();
assert.deepEqual(ro.issues, []);
});
test('NO FAIL-OPEN: OI-cap list down = CAUTION + low confidence + sources_failed (never OK); book down = CAUTION medium; metadata down = 503 UNKNOWN, never settled', async () => {
const oi = await makeApp({ net: mockVenues({ fail: new Set(['perpsAtOpenInterestCap']) }) });
const r = await (await v2pay(oi.fetchApp)(`${BASE}/v1/full?${CLEAN}`)).json();
assert.equal(r.verdict, 'CAUTION'); assert.equal(r.confidence, 'low'); assert.deepEqual(r.sources_failed, ['perpsAtOpenInterestCap']);
assert.ok(r.warnings.some((w) => w.code === 'oi_cap_unknown'));
_clearCache();
const book = await makeApp({ net: mockVenues({ fail: new Set(['l2Book']) }) });
const b = await (await v2pay(book.fetchApp)(`${BASE}/v1/full?${CLEAN}`)).json();
assert.equal(b.verdict, 'CAUTION'); assert.equal(b.confidence, 'medium'); assert.deepEqual(b.sources_failed, ['l2Book']); assert.equal(b.execution, undefined);
_clearCache();
const stale = await makeApp({ net: mockVenues({ fail: new Set(['stale_book']) }) });
const s = await (await v2pay(stale.fetchApp)(`${BASE}/v1/lite?${CLEAN}`)).json();
assert.equal(s.verdict, 'CAUTION'); assert.deepEqual(s.sources_failed, ['l2Book_stale_or_empty']);
_clearCache();
for (const fail of [['metaAndAssetCtxs'], ['primary', 'fallback'], ['garbage']]) {
_clearCache();
const down = await makeApp({ net: mockVenues({ fail: new Set(fail) }) });
for (const [tier] of ROUTES) {
const x = await v2pay(down.fetchApp)(`${BASE}/v1/${tier}?${CLEAN}`);
assert.equal(x.status, 503, `${fail} ${tier}`); const xj = await x.json();
assert.equal(xj.verdict, 'UNKNOWN'); assert.equal(xj.confidence, 'low'); assert.equal(xj.charged, false); assert.ok(xj.sources_failed.includes('metaAndAssetCtxs')); assert.match(xj.disclaimer, NFA);
}
const p = await down.fetchApp(`${BASE}/v1/preview?${CLEAN}`); assert.equal(p.status, 503);
assert.equal(down.facilitator.settled.length, 0, 'never settled');
}
});
test('fallbacks: primary host down -> api-ui fallback answers (OK, high confidence); primary timeout -> fallback', async () => {
const calls = [];
const fb = await makeApp({ net: mockVenues({ fail: new Set(['primary']), calls }) });
const r = await (await v2pay(fb.fetchApp)(`${BASE}/v1/full?${CLEAN}`)).json();
assert.equal(r.verdict, 'OK'); assert.equal(r.confidence, 'high');
assert.ok(calls.some((c) => c.startsWith('api-ui.hyperliquid.xyz')));
_clearCache();
const realSet = globalThis.setTimeout; globalThis.setTimeout = (fn, ms, ...a) => realSet(fn, Math.min(ms, 30), ...a);
try {
const slow = await makeApp({ net: mockVenues({ fail: new Set(['primary:timeout']) }) });
const t = await (await v2pay(slow.fetchApp)(`${BASE}/v1/lite?${CLEAN}`)).json();
assert.equal(t.verdict, 'OK');
_clearCache();
const dead = await makeApp({ net: mockVenues({ fail: new Set(['metaAndAssetCtxs:timeout']) }) });
const d = await v2pay(dead.fetchApp)(`${BASE}/v1/lite?${CLEAN}`);
assert.equal(d.status, 503); assert.equal(dead.facilitator.settled.length, 0);
} finally { globalThis.setTimeout = realSet; }
});
test('unknown coin = 404 unknown_coin (not settled)', async () => {
const { fetchApp, facilitator } = await makeApp();
const r = await v2pay(fetchApp)(`${BASE}/v1/lite?coin=ZZZQ&side=long&size=1`);
assert.equal(r.status, 404); const j = await r.json(); assert.equal(j.error, 'unknown_coin'); assert.match(j.disclaimer, NFA);
assert.equal(facilitator.settled.length, 0);
});
test('free preview: verdict + codes only (no messages, fixes, market facts), upgrade text, rate limit', async () => {
let n = 0; const limiter = { limit: async () => ({ success: ++n <= 2 }) };
const { fetchApp } = await makeApp({ limiter });
const r = await fetchApp(`${BASE}/v1/preview?${BAD}`);
assert.equal(r.status, 200); const j = await r.json();
assert.equal(j.verdict, 'REJECT_LIKELY'); assert.ok(j.issue_codes.includes('price_tick')); assert.equal(j.issues, undefined); assert.equal(j.liquidation, undefined);
assert.equal(j.full.prices.full, '$0.01'); assert.equal(j.full.prices.lite, '$0.003'); assert.match(j.disclaimer, NFA); assert.match(j.upgrade, /30 calls\/day/);
assert.equal((await fetchApp(`${BASE}/v1/preview?${CLEAN}`)).status, 200);
assert.equal((await fetchApp(`${BASE}/v1/preview?${CLEAN}`)).status, 429);
});
test('remote MCP: initialize (one version), tools/list, tools/call check_perp_order full result + free tier', async () => {
const { fetchApp } = await makeApp();
const init = await (await mcp(fetchApp, { method: 'initialize', params: { protocolVersion: '2025-06-18' } })).json();
assert.equal(init.result.serverInfo.name, 'perpcheck'); assert.equal(init.result.serverInfo.version, VERSION_STR); assert.match(init.result.instructions, NFA);
const list = await (await mcp(fetchApp, { method: 'tools/list' })).json();
assert.deepEqual(list.result.tools.map((t) => t.name), ['check_perp_order']); assert.equal(list.result.tools[0].annotations.readOnlyHint, true);
const call = await (await mcp(fetchApp, { method: 'tools/call', params: { name: 'check_perp_order', arguments: { coin: 'ETH', side: 'long', size: '0.0012345', price: '3000.123', leverage: 60 } } })).json();
assert.equal(call.result.isError, false); assert.equal(call.result.structuredContent.verdict, 'REJECT_LIKELY'); assert.ok(call.result.structuredContent.issues.some((i) => i.fix === 3000.1));
assert.match(call.result.content[0].text, /ISSUE price_tick/); assert.match(call.result.content[0].text, NFA); assert.equal(call.result.structuredContent.free_tier.remaining_today, 29);
const bad = await (await mcp(fetchApp, { method: 'tools/call', params: { name: 'check_perp_order', arguments: { coin: '!!', side: 'long', size: 1 } } })).json();
assert.equal(bad.result.isError, true); assert.match(bad.result.content[0].text, NFA);
});
test('discovery: info JSON, landing HTML, openapi (paid + preview only), /.well-known/x402, PNG favicon/logo, health, one version string', async () => {
const { fetchApp } = await makeApp();
const info = await (await fetchApp(`${BASE}/`, { headers: { accept: 'application/json' } })).json();
assert.equal(info.name, 'PerpCheck'); assert.deepEqual(info.paid.map((p) => [p.path, p.price]), [['/v1/lite', '$0.003'], ['/v1/full', '$0.01']]);
assert.match(info.disclaimer, NFA); assert.match(info.policy.custody, /never places, signs or holds orders/); assert.equal(info.engine, `perpcheck-${VERSION_STR}`); assert.equal(info.mcpRegistry, 'co.youraitools/perpcheck');
const html = await (await fetchApp(`${BASE}/`, { headers: { accept: 'text/html' } })).text();
assert.match(html, /Perp<span>Check/); assert.match(html, NFA); assert.ok(!/guarantee[ds]? (profit|return)/i.test(html)); assert.match(html, /claude mcp add --transport http perpcheck/); assert.match(html, /cursor:\/\/anysphere/);
const oa = await (await fetchApp(`${BASE}/openapi.json`)).json();
assert.deepEqual(Object.keys(oa.paths).sort(), ['/v1/full', '/v1/lite', '/v1/preview']);
assert.equal(oa.paths['/v1/full'].get['x-payment-info'].price.amount, '0.01'); assert.equal(oa.info.version, VERSION_STR); assert.equal(oa.servers[0].url, BASE); assert.match(oa.info.description, NFA);
const wk = await (await fetchApp(`${BASE}/.well-known/x402`)).json();
assert.deepEqual(wk.resources, [`${BASE}/v1/lite`, `${BASE}/v1/full`]);
for (const p of ['/favicon.ico', '/favicon.png', '/logo.png']) { const r = await fetchApp(`${BASE}${p}`); assert.equal(r.headers.get('content-type'), 'image/png'); const b = new Uint8Array(await r.arrayBuffer()); assert.deepEqual([...b.slice(1, 4)], [80, 78, 71]); }
const h = await (await fetchApp(`${BASE}/health`)).json(); assert.equal(h.engine, `perpcheck-${VERSION_STR}`);
const hs = await (await fetchApp(`${BASE}/health?sources=1`)).json(); assert.equal(hs.sources.hyperliquid_info.status, 200);
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))); assert.equal(pkg.version, VERSION_STR); assert.equal(pkg.mcpName, 'co.youraitools/perpcheck');
const sj = JSON.parse(fs.readFileSync(new URL('../server.json', import.meta.url))); assert.equal(sj.version, VERSION_STR); assert.equal(sj.packages, undefined); assert.ok(sj.remotes.length >= 1);
});
test('stdio MCP: validates locally, uses the hosted preview, no key = free mode', async () => {
assert.deepEqual(STDIO_TOOLS.map((t) => t.name), ['check_perp_order']);
assert.equal(toOrder({ coin: 'eth', side: 'long', size: '0.01' }).query.coin, 'eth'); assert.ok(toOrder({ coin: '<x>', side: 'long', size: 1 }).error); assert.ok(toOrder({}).error);
const seen = [];
const fetchImpl = async (u) => { seen.push(String(u)); return Response.json({ coin: 'ETH', side: 'long', verdict: 'REJECT_LIKELY', issue_codes: ['price_tick'], warning_codes: [], confidence: 'high', sources_failed: [] }); };
const r = await stdioHandle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'check_perp_order', arguments: { coin: 'ETH', side: 'long', size: '0.01', price: '3000.123' } } }, { fetchImpl, env: {} });
assert.match(r.result.content[0].text, /PerpCheck ETH long: REJECT_LIKELY; issues: price_tick/); assert.match(r.result.content[0].text, NFA);
assert.equal(seen[0], 'https://api.youraitools.co/perpcheck/v1/preview?coin=ETH&side=long&size=0.01&price=3000.123&leverage=1&margin=cross');
const init = await stdioHandle({ jsonrpc: '2.0', id: 2, method: 'initialize', params: {} }, { env: {} });
assert.equal(init.result.serverInfo.version, VERSION_STR);
const down = await stdioHandle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check_perp_order', arguments: { coin: 'ETH', side: 'long', size: '0.01' } } }, { fetchImpl: async () => { throw new Error('offline'); }, env: {} });
assert.equal(down.result.isError, true); assert.match(down.result.content[0].text, /UNKNOWN; do not treat this as OK/);
});
agentgate-source: code mirror on Cloudflare Pages. All repos