diff --git a/package.json b/package.json index 5e3388e..9a2abe0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hashlock-tech/mcp", - "version": "0.3.0", + "version": "0.4.0", "mcpName": "io.github.Hashlock-Tech/hashlock", "description": "Hashlock Markets — atomic settlement layer for the agent economy. Sealed-bid RFQ + HTLC atomic settlement, fused into one operation. Live on Ethereum and Sui mainnets; Bitcoin HTLC mainnet-ready via P2WSH scripts (no contract to deploy; signet-validated); Base, Arbitrum, Solana, TON on the roadmap. The settlement primitive AI agents use to trade across chains. MCP-native, retry-safe, signed receipts. NOT the cryptographic HTLC primitive. NOT affiliated with Hashlock Pty Ltd (hashlock.com).", "license": "MIT", diff --git a/server.json b/server.json index c6146a4..e8f2e3e 100644 --- a/server.json +++ b/server.json @@ -8,14 +8,14 @@ "source": "github", "id": "1206275989" }, - "version": "1.3.0", + "version": "1.4.0", "websiteUrl": "https://hashlock.markets", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@hashlock-tech/mcp", - "version": "0.3.0", + "version": "0.4.0", "runtimeHint": "npx", "transport": { "type": "stdio" }, "runtimeArguments": [ diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts new file mode 100644 index 0000000..c688fd7 --- /dev/null +++ b/src/__tests__/swap.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect } from 'vitest'; +import { compareDecimal, limitSatisfied } from '../lib/swap.js'; + +describe('compareDecimal (string decimals, no float drift)', () => { + it('orders integers and fractions without float error', () => { + expect(compareDecimal('100.2', '100.19')).toBe(1); + expect(compareDecimal('100.19', '100.2')).toBe(-1); + expect(compareDecimal('3450.00', '3450')).toBe(0); + expect(compareDecimal('0.30000000000000004', '0.3')).toBe(1); + expect(compareDecimal('1000000000000000000', '999999999999999999')).toBe(1); + expect(compareDecimal('007.50', '7.5')).toBe(0); + }); +}); + +describe('limitSatisfied (directional — SELL floor, BUY ceiling)', () => { + it('SELL: accept iff best >= limit (limit is a floor)', () => { + expect(limitSatisfied('3500', '3400', 'SELL')).toBe(true); + expect(limitSatisfied('3400', '3400', 'SELL')).toBe(true); + expect(limitSatisfied('3399.99', '3400', 'SELL')).toBe(false); + }); + it('BUY: accept iff best <= limit (limit is a ceiling)', () => { + expect(limitSatisfied('3400', '3500', 'BUY')).toBe(true); + expect(limitSatisfied('3500', '3500', 'BUY')).toBe(true); + expect(limitSatisfied('3500.01', '3500', 'BUY')).toBe(false); + }); +}); + +import { selectBestBid } from '../lib/swap.js'; + +const q = (over: Partial): import('../lib/swap.js').SwapQuote => ({ + id: 'q', rfqId: 'r', marketMakerId: 'mm', price: '100', amount: '10', + status: 'PENDING', ...over, +}); + +describe('selectBestBid', () => { + it('SELL picks the HIGHEST price among eligible', () => { + const best = selectBestBid( + [q({ id: 'a', price: '3400' }), q({ id: 'b', price: '3500' }), q({ id: 'c', price: '3450' })], + 'SELL', '10'); + expect(best?.id).toBe('b'); + }); + it('BUY picks the LOWEST price among eligible', () => { + const best = selectBestBid( + [q({ id: 'a', price: '3400' }), q({ id: 'b', price: '3500' }), q({ id: 'c', price: '3350' })], + 'BUY', '10'); + expect(best?.id).toBe('c'); + }); + it('excludes non-PENDING quotes', () => { + const best = selectBestBid( + [q({ id: 'a', price: '9999', status: 'EXPIRED' }), q({ id: 'b', price: '3400', status: 'PENDING' })], + 'SELL', '10'); + expect(best?.id).toBe('b'); + }); + it('excludes quotes that do not cover the requested amount (full-fill v1)', () => { + const best = selectBestBid( + [q({ id: 'a', price: '9999', amount: '5' }), q({ id: 'b', price: '3400', amount: '10' })], + 'SELL', '10'); + expect(best?.id).toBe('b'); + }); + it('accepts a quote whose amount exceeds the request', () => { + const best = selectBestBid([q({ id: 'a', price: '3400', amount: '50' })], 'SELL', '10'); + expect(best?.id).toBe('a'); + }); + it('returns null when no eligible quote', () => { + expect(selectBestBid([], 'SELL', '10')).toBeNull(); + expect(selectBestBid([q({ status: 'REJECTED' })], 'SELL', '10')).toBeNull(); + expect(selectBestBid([q({ amount: '1' })], 'SELL', '10')).toBeNull(); + }); +}); + +import { pollForQuotes, type SwapClient, runSwapQuote } from '../lib/swap.js'; +import type { Remember } from '../lib/swap.js'; + +function fakeClient(over: Partial): SwapClient { + return { + createRFQ: async () => ({ id: 'r1', status: 'ACTIVE' }), + getRFQ: async () => ({ id: 'r1', side: 'SELL', amount: '10', status: 'ACTIVE' }), + getQuotes: async () => [], + acceptQuote: async () => ({ id: 'q', rfqId: 'r1', status: 'ACCEPTED', trade: { id: 't1', status: 'PROPOSED' } }), + cancelRFQ: async () => ({ id: 'r1', status: 'CANCELLED' }), + ...over, + }; +} +const noSleep = async () => {}; + +describe('pollForQuotes', () => { + it('early-returns as soon as an eligible bid exists', async () => { + let calls = 0; + const client = fakeClient({ + getQuotes: async () => { + calls += 1; + return calls < 2 ? [] : [{ id: 'q1', rfqId: 'r1', marketMakerId: 'mm', price: '3400', amount: '10', status: 'PENDING' }]; + }, + }); + const quotes = await pollForQuotes(client, 'r1', 'SELL', '10', 20, noSleep); + expect(quotes).toHaveLength(1); + expect(calls).toBe(2); + }); + it('stops after the bounded window when no eligible bid arrives', async () => { + let calls = 0; + const client = fakeClient({ getQuotes: async () => { calls += 1; return []; } }); + const quotes = await pollForQuotes(client, 'r1', 'SELL', '10', 20, noSleep); + expect(quotes).toEqual([]); + expect(calls).toBe(9); + }); + it('caps wait at 25s (negative/huge inputs clamped)', async () => { + let calls = 0; + const client = fakeClient({ getQuotes: async () => { calls += 1; return []; } }); + await pollForQuotes(client, 'r1', 'SELL', '10', 9999, noSleep); + expect(calls).toBeLessThanOrEqual(11); + }); +}); + +const passthrough: Remember = (op) => op(); + +function parse(content: { content: { text: string }[] }) { + return JSON.parse(content.content[0].text); +} + +describe('runSwapQuote', () => { + it('opens RFQ with isBlind defaulting true and returns a QUOTED handle when a bid arrives', async () => { + let createInput: any; + const client = fakeClient({ + createRFQ: async (i: unknown) => { createInput = i; return { id: 'rfq-9', status: 'ACTIVE' }; }, + getQuotes: async () => [{ id: 'q1', rfqId: 'rfq-9', marketMakerId: 'mm', price: '3500', amount: '2', status: 'PENDING' }], + }); + const out = parse(await runSwapQuote(client, { + side: 'SELL', baseToken: 'ETH', quoteToken: 'USDT', amount: '2', limit_price: '3400', + }, { sleep: noSleep, remember: passthrough })); + + expect(createInput.isBlind).toBe(true); + expect('limit_price' in createInput).toBe(false); + expect(out.swap_handle).toBe('rfq-9'); + expect(out.status).toBe('QUOTED'); + expect(out.best_bid.quote_id).toBe('q1'); + expect(out.best_bid.price).toBe('3500'); + expect(out.bids_seen).toBe(1); + expect(out.limit_price).toBe('3400'); + }); + + it('returns an OPEN handle with null best_bid when no bid arrives in the window', async () => { + const client = fakeClient({ createRFQ: async () => ({ id: 'rfq-x', status: 'ACTIVE' }), getQuotes: async () => [] }); + const out = parse(await runSwapQuote(client, { side: 'BUY', baseToken: 'ETH', quoteToken: 'USDT', amount: '1' }, + { sleep: noSleep, remember: passthrough })); + expect(out.status).toBe('OPEN'); + expect(out.best_bid).toBeNull(); + expect(out.still_open).toBe(true); + }); + + // FIX A (CodeRabbit PR#7, Minor): still_open must reflect a POST-poll RFQ + // status refresh, not the stale create-time status (the RFQ can expire during + // the bounded wait when expiresIn <= the wait window). + it('still_open true when the RFQ stays open through the wait (getRFQ ACTIVE)', async () => { + const client = fakeClient({ + createRFQ: async () => ({ id: 'rq', status: 'ACTIVE' }), + getRFQ: async () => ({ id: 'rq', side: 'SELL', amount: '1', status: 'ACTIVE' }), + getQuotes: async () => [], + }); + const out = parse(await runSwapQuote(client, { side: 'SELL', baseToken: 'ETH', quoteToken: 'USDT', amount: '1' }, + { sleep: noSleep, remember: passthrough })); + expect(out.still_open).toBe(true); + expect(out.status).toBe('OPEN'); + }); + + it('still_open false when the RFQ EXPIRES during the bounded wait', async () => { + const client = fakeClient({ + createRFQ: async () => ({ id: 'rq', status: 'ACTIVE' }), + getRFQ: async () => ({ id: 'rq', side: 'SELL', amount: '1', status: 'EXPIRED' }), + getQuotes: async () => [], + }); + const out = parse(await runSwapQuote(client, { side: 'SELL', baseToken: 'ETH', quoteToken: 'USDT', amount: '1' }, + { sleep: noSleep, remember: passthrough })); + expect(out.still_open).toBe(false); + expect(out.status).toBe('OPEN'); // status still maps from bids (no eligible bid) + }); + + it('post-poll getRFQ throw falls back to create-time status (still_open true, no throw)', async () => { + const client = fakeClient({ + createRFQ: async () => ({ id: 'rq', status: 'ACTIVE' }), + getRFQ: async () => { throw new Error('network blip'); }, + getQuotes: async () => [], + }); + const out = parse(await runSwapQuote(client, { side: 'SELL', baseToken: 'ETH', quoteToken: 'USDT', amount: '1' }, + { sleep: noSleep, remember: passthrough })); + expect(out.still_open).toBe(true); + expect(out.swap_handle).toBe('rq'); + expect(out.status).toBe('OPEN'); + }); + + it('honors private:false override', async () => { + let createInput: any; + const client = fakeClient({ createRFQ: async (i: unknown) => { createInput = i; return { id: 'r', status: 'ACTIVE' }; }, getQuotes: async () => [] }); + await runSwapQuote(client, { side: 'SELL', baseToken: 'ETH', quoteToken: 'USDT', amount: '1', private: false }, + { sleep: noSleep, remember: passthrough }); + expect(createInput.isBlind).toBe(false); + }); +}); + +import { runSwapExecute, isPositiveDecimal } from '../lib/swap.js'; + +const SELL_RFQ = { id: 'r1', side: 'SELL' as const, amount: '10', status: 'QUOTES_RECEIVED' }; +const pendingBids = [ + { id: 'qa', rfqId: 'r1', marketMakerId: 'mm', price: '3400', amount: '10', status: 'PENDING' }, + { id: 'qb', rfqId: 'r1', marketMakerId: 'mm', price: '3500', amount: '10', status: 'PENDING' }, +]; + +describe('runSwapExecute', () => { + it('CONFIRMATION_REQUIRED when neither quote_id nor limit_price given', async () => { + const client = fakeClient({ getRFQ: async () => SELL_RFQ, getQuotes: async () => pendingBids }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1' }, passthrough)); + expect(out.outcome).toBe('CONFIRMATION_REQUIRED'); + }); + it('accepts the best bid when the sealed limit is satisfied (SELL floor)', async () => { + let accepted: string | undefined; + const client = fakeClient({ + getRFQ: async () => SELL_RFQ, getQuotes: async () => pendingBids, + acceptQuote: async (id: string) => { accepted = id; return { id, rfqId: 'r1', status: 'ACCEPTED', trade: { id: 't9', status: 'PROPOSED' } }; }, + }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1', limit_price: '3450' }, passthrough)); + expect(accepted).toBe('qb'); + expect(out.trade_id).toBe('t9'); + expect(out.accepted_price).toBe('3500'); + expect(out.accepted_amount).toBe('10'); + }); + it('NO_ACCEPTABLE_FILL when best bid misses the SELL floor', async () => { + const client = fakeClient({ getRFQ: async () => SELL_RFQ, getQuotes: async () => pendingBids }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1', limit_price: '9999' }, passthrough)); + expect(out.outcome).toBe('NO_ACCEPTABLE_FILL'); + expect(out.best_price).toBe('3500'); + }); + it('explicit quote_id path accepts exactly that quote', async () => { + let accepted: string | undefined; + const client = fakeClient({ + getRFQ: async () => SELL_RFQ, getQuotes: async () => pendingBids, + acceptQuote: async (id: string) => { accepted = id; return { id, rfqId: 'r1', status: 'ACCEPTED', trade: { id: 't1', status: 'PROPOSED' } }; }, + }); + await runSwapExecute(client, { swap_handle: 'r1', quote_id: 'qa' }, passthrough); + expect(accepted).toBe('qa'); + }); + it('QUOTE_NOT_AVAILABLE when the given quote_id is not an eligible bid', async () => { + const client = fakeClient({ getRFQ: async () => SELL_RFQ, getQuotes: async () => pendingBids }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1', quote_id: 'ghost' }, passthrough)); + expect(out.outcome).toBe('QUOTE_NOT_AVAILABLE'); + }); + it('SWAP_NOT_OPEN when the RFQ is in a terminal state', async () => { + const client = fakeClient({ + getRFQ: async () => ({ ...SELL_RFQ, status: 'CANCELLED' }), + getQuotes: async () => { throw new Error('getQuotes must NOT be called'); }, + acceptQuote: async () => { throw new Error('acceptQuote must NOT be called'); }, + }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1', limit_price: '1' }, passthrough)); + expect(out.outcome).toBe('SWAP_NOT_OPEN'); + expect(out.rfq_status).toBe('CANCELLED'); + }); + it('SWAP_NOT_FOUND when the handle is unknown', async () => { + const client = fakeClient({ + getRFQ: async () => null, + getQuotes: async () => { throw new Error('getQuotes must NOT be called'); }, + acceptQuote: async () => { throw new Error('acceptQuote must NOT be called'); }, + }); + const out = parse(await runSwapExecute(client, { swap_handle: 'nope', limit_price: '1' }, passthrough)); + expect(out.outcome).toBe('SWAP_NOT_FOUND'); + }); + + // FIX 1 (F1): forbidden/unauthorized RFQ must collapse to uniform SWAP_NOT_FOUND + it('forbidden RFQ (not a participant) collapses to SWAP_NOT_FOUND (no oracle)', async () => { + const client = fakeClient({ + getRFQ: async () => { throw new Error('You are not a participant of this RFQ'); }, + getQuotes: async () => { throw new Error('getQuotes must NOT be called'); }, + acceptQuote: async () => { throw new Error('acceptQuote must NOT be called'); }, + }); + const out = parse(await runSwapExecute(client, { swap_handle: 'someone-elses', limit_price: '1' }, passthrough)); + expect(out.outcome).toBe('SWAP_NOT_FOUND'); + expect(out.swap_handle).toBe('someone-elses'); + expect(out.next).toBe('Verify the swap_handle, or open a fresh swap with swap_quote.'); + }); + it('non-forbidden getRFQ throw propagates (not over-broadly swallowed)', async () => { + const client = fakeClient({ getRFQ: async () => { throw new Error('network down'); } }); + await expect(runSwapExecute(client, { swap_handle: 'r1', limit_price: '1' }, passthrough)) + .rejects.toThrow('network down'); + }); + + // FIX 2 (F4): fail-closed price/amount validation + it('selectBestBid excludes a higher-but-malformed-price quote', () => { + const best = selectBestBid( + [q({ id: 'bad', price: '9,999', amount: '10' }), q({ id: 'good', price: '3400', amount: '10' })], + 'SELL', '10'); + expect(best?.id).toBe('good'); + }); + it('runSwapExecute rejects a malformed agent limit_price with INVALID_LIMIT_PRICE', async () => { + // Per the M1 control order getQuotes runs before the limit grammar check; + // the load-bearing invariant is that no money is moved (acceptQuote unreached). + const client = fakeClient({ + getRFQ: async () => SELL_RFQ, + getQuotes: async () => pendingBids, + acceptQuote: async () => { throw new Error('acceptQuote must NOT be called'); }, + }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1', limit_price: '3,500' }, passthrough)); + expect(out.outcome).toBe('INVALID_LIMIT_PRICE'); + expect(out.limit_price).toBe('3,500'); + }); + it('isPositiveDecimal accepts well-formed and rejects malformed', () => { + expect(isPositiveDecimal('3450.00')).toBe(true); + expect(isPositiveDecimal('1')).toBe(true); + expect(isPositiveDecimal(' 3450 ')).toBe(true); + expect(isPositiveDecimal('3,500')).toBe(false); + expect(isPositiveDecimal('1e3')).toBe(false); + expect(isPositiveDecimal('')).toBe(false); + expect(isPositiveDecimal('abc')).toBe(false); + expect(isPositiveDecimal('-1')).toBe(false); + expect(isPositiveDecimal('.5')).toBe(false); + }); + + // TEST HARDENING: NO_ACCEPTABLE_FILL with empty quotes + it('NO_ACCEPTABLE_FILL with empty quotes (best is null)', async () => { + const client = fakeClient({ getRFQ: async () => SELL_RFQ, getQuotes: async () => [] }); + const out = parse(await runSwapExecute(client, { swap_handle: 'r1', limit_price: '1' }, passthrough)); + expect(out.outcome).toBe('NO_ACCEPTABLE_FILL'); + expect(out.best_price).toBeNull(); + }); + + // FIX B (CodeRabbit PR#7, Major money-path): both quote_id AND limit_price is + // ambiguous on a real-funds accept — must reject WITHOUT fetching quotes or accepting. + it('INVALID_EXECUTION_PARAMS when BOTH quote_id and limit_price given (no getQuotes, no acceptQuote)', async () => { + const client = fakeClient({ + getRFQ: async () => SELL_RFQ, + getQuotes: async () => { throw new Error('getQuotes must NOT be called'); }, + acceptQuote: async () => { throw new Error('acceptQuote must NOT be called'); }, + }); + const out = parse(await runSwapExecute( + client, { swap_handle: 'r1', quote_id: 'qa', limit_price: '3450' }, passthrough)); + expect(out.outcome).toBe('INVALID_EXECUTION_PARAMS'); + expect(out.swap_handle).toBe('r1'); + }); + + // TEST HARDENING: BUY-side happy path (lowest price wins, ceiling satisfied) + it('BUY-side accepts the LOWEST covering bid within the ceiling', async () => { + let accepted: string | undefined; + const buyRfq = { id: 'rb', side: 'BUY' as const, amount: '10', status: 'ACTIVE' }; + const buyBids = [ + { id: 'bhi', rfqId: 'rb', marketMakerId: 'mm', price: '3500', amount: '10', status: 'PENDING' }, + { id: 'blo', rfqId: 'rb', marketMakerId: 'mm', price: '3400', amount: '10', status: 'PENDING' }, + ]; + const client = fakeClient({ + getRFQ: async () => buyRfq, getQuotes: async () => buyBids, + acceptQuote: async (id: string) => { accepted = id; return { id, rfqId: 'rb', status: 'ACCEPTED', trade: { id: 'tb', status: 'PROPOSED' } }; }, + }); + const out = parse(await runSwapExecute(client, { swap_handle: 'rb', limit_price: '3450' }, passthrough)); + expect(accepted).toBe('blo'); + expect(out.accepted_price).toBe('3400'); + }); +}); + +import { runSwapStatus, runSwapCancel } from '../lib/swap.js'; + +describe('runSwapStatus', () => { + it('reconstructs swap state from the handle alone', async () => { + const client = fakeClient({ + getRFQ: async () => ({ id: 'r1', side: 'SELL', amount: '10', status: 'QUOTES_RECEIVED' }), + getQuotes: async () => [{ id: 'q1', rfqId: 'r1', marketMakerId: 'mm', price: '3500', amount: '10', status: 'PENDING' }], + }); + const out = parse(await runSwapStatus(client, { swap_handle: 'r1' }, noSleep)); + expect(out.swap_handle).toBe('r1'); + expect(out.status).toBe('QUOTED'); + expect(out.best_bid.quote_id).toBe('q1'); + expect(out.still_open).toBe(true); + }); + it('SWAP_NOT_FOUND for an unknown handle', async () => { + const client = fakeClient({ getRFQ: async () => null }); + const out = parse(await runSwapStatus(client, { swap_handle: 'x' }, noSleep)); + expect(out.outcome).toBe('SWAP_NOT_FOUND'); + }); + it('surfaces a terminal RFQ state with still_open false', async () => { + const client = fakeClient({ getRFQ: async () => ({ id: 'r1', side: 'SELL', amount: '10', status: 'EXPIRED' }), getQuotes: async () => [] }); + const out = parse(await runSwapStatus(client, { swap_handle: 'r1' }, noSleep)); + expect(out.still_open).toBe(false); + expect(out.rfq_status).toBe('EXPIRED'); + expect(out.status).toBe('OPEN'); + }); + + // F1 parity: forbidden/unauthorized RFQ must collapse to uniform SWAP_NOT_FOUND (same as runSwapExecute) + it('forbidden RFQ collapses to SWAP_NOT_FOUND (no oracle, parity with runSwapExecute)', async () => { + const client = fakeClient({ + getRFQ: async () => { throw new Error('You are not a participant of this RFQ'); }, + getQuotes: async () => { throw new Error('getQuotes must NOT be called'); }, + }); + const out = parse(await runSwapStatus(client, { swap_handle: 'someone-elses' }, noSleep)); + expect(out.outcome).toBe('SWAP_NOT_FOUND'); + }); + it('a non-forbidden getRFQ error propagates (not swallowed)', async () => { + const client = fakeClient({ getRFQ: async () => { throw new Error('network down'); } }); + await expect(runSwapStatus(client, { swap_handle: 'r1' }, noSleep)).rejects.toThrow('network down'); + }); +}); + +describe('runSwapCancel', () => { + it('cancels the RFQ and reports the resulting status', async () => { + let cancelled: string | undefined; + const client = fakeClient({ cancelRFQ: async (id: string) => { cancelled = id; return { id, status: 'CANCELLED' }; } }); + const out = parse(await runSwapCancel(client, { swap_handle: 'r1' }, passthrough)); + expect(cancelled).toBe('r1'); + expect(out.swap_handle).toBe('r1'); + expect(out.status).toBe('CANCELLED'); + }); + it('forbidden RFQ collapses to SWAP_NOT_FOUND (no oracle, parity with execute/status)', async () => { + const client = fakeClient({ cancelRFQ: async () => { throw new Error('You are not a participant of this RFQ'); } }); + const out = parse(await runSwapCancel(client, { swap_handle: 'someone-elses' }, passthrough)); + expect(out.outcome).toBe('SWAP_NOT_FOUND'); + }); + it('a non-forbidden cancelRFQ error propagates (not swallowed)', async () => { + const client = fakeClient({ cancelRFQ: async () => { throw new Error('network down'); } }); + await expect(runSwapCancel(client, { swap_handle: 'r1' }, passthrough)).rejects.toThrow('network down'); + }); +}); diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 913a584..51c4e2b 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -530,6 +530,40 @@ describe('MCP Tool → SDK Integration', () => { }); }); + describe('layer-2 swap facade descriptions + router boundary (pinned)', () => { + let source: string; + beforeEach(async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const url = await import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + source = await fs.readFile(path.resolve(here, '..', 'index.ts'), 'utf8'); + }); + + it('registers all four swap tools', () => { + for (const t of ['swap_quote', 'swap_status', 'swap_execute', 'swap_cancel']) { + expect(source).toContain(`'${t}'`); + } + }); + it('swap_quote DO NOT USE WHEN routes maker-aware control to create_rfq', () => { + expect(source).toMatch(/swap_quote[\s\S]*DO NOT USE WHEN:[\s\S]*create_rfq/); + }); + it('swap_execute description states real-funds confirm + sealed limit semantics', () => { + expect(source).toMatch(/swap_execute[\s\S]*limit_price[\s\S]*(SELL|floor)/); + expect(source).toMatch(/swap_execute[\s\S]*Real funds/); + }); + it('swap_execute warns accepted_amount may exceed requested (full-fill v1)', () => { + expect(source).toMatch(/accepted_amount may EXCEED your requested amount/); + }); + it('swap_quote declares the private (Ghost Auction) default ON', () => { + expect(source).toMatch(/private[\s\S]*default[\s\S]*(true|ON)/i); + }); + it('create_rfq intent-compiler remains byte-stable (unchanged Layer-1 pins)', () => { + expect(source).toContain('INTENT → PARAMS MAPPING'); + expect(source).toMatch(/RESTATE/); + }); + }); + // ─── Error scenarios ─────────────────────────────────── describe('error handling', () => { diff --git a/src/index.ts b/src/index.ts index 54923f4..fa97e81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,10 @@ import { okContent } from './lib/result.js'; import { wrapTool } from './lib/errors.js'; import { SUPPORTED_PAIRS } from './lib/pairs.js'; import { createIdempotencyGuard, idempotencyKey } from './lib/idempotency.js'; +import { + runSwapQuote, runSwapStatus, runSwapExecute, runSwapCancel, + type SwapClient, +} from './lib/swap.js'; // Default to the direct api-gateway endpoint (/graphql), NOT the browser-only // SSR proxy at /api/graphql. The SSR proxy reads the httpOnly `api-token` @@ -30,7 +34,7 @@ const idempotency = createIdempotencyGuard(); const server = new McpServer({ name: 'hashlock', - version: '0.3.0', + version: '0.4.0', }); // ─── create_htlc ───────────────────────────────────────────── @@ -311,6 +315,99 @@ server.tool( )), ); +// The real HashLock instance structurally satisfies SwapClient; same cast +// style as the existing create_rfq / list_my_trades call sites. +const swapClient = hl as unknown as SwapClient; +const realSleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ─── swap_quote ────────────────────────────────────────────── +server.tool( + 'swap_quote', + [ + 'One-call OTC swap intake for agents — opens a sealed-bid Ghost Auction under the hood and waits briefly for the first private market-maker bids, then hands back a swap_handle + the best bid so far. Async by design: there is NO public synchronous price (that is the privacy guarantee). Zero slippage: the bid you execute is the fill.', + '', + 'USE WHEN: an agent/user wants to "just swap X for Y" and have the facade manage quote collection + best-bid selection + a price guard. Privacy-sensitive or large flow.', + 'DO NOT USE WHEN: the caller wants explicit market-maker-aware RFQ control and will pick/accept quotes itself — use create_rfq. Sub-second DEX fills — use a DEX aggregator.', + '', + 'PARAM NOTES: `limit_price` is your sealed reservation — for SELL it is a FLOOR (min you will accept), for BUY a CEILING (max you will pay), per unit of base in quote-token terms. It is NEVER sent to makers. `private` defaults true (Ghost Auction ON — hides your identity from bidders); set false for an open auction. After this returns, call swap_execute (with the same limit_price, or best_bid.quote_id) to take it, swap_status to let competition build, or swap_cancel to abort. Real funds: restate the resolved deal to the user before executing.', + ].join('\n'), + { + side: z.enum(['BUY', 'SELL']).describe('SELL = dispose of baseToken; BUY = acquire baseToken.'), + baseToken: z.string().describe('Base asset symbol (see list_supported_pairs).'), + baseChain: z.enum(['ethereum', 'sepolia', 'bitcoin', 'bitcoin-signet', 'sui', 'sui-testnet']).optional().describe('Chain the base token settles on.'), + quoteToken: z.string().describe('Quote asset symbol.'), + quoteChain: z.enum(['ethereum', 'sepolia', 'bitcoin', 'bitcoin-signet', 'sui', 'sui-testnet']).optional().describe('Chain the quote token settles on.'), + amount: z.string().describe('Base-token amount as a raw decimal string ("0.1", "2"). Do NOT convert to wei/satoshis.'), + limit_price: z.string().optional().describe('Sealed reservation. SELL=floor, BUY=ceiling, per unit of base in quote terms. Never sent to makers.'), + private: z.boolean().optional().describe('Ghost Auction (hide requester identity). Default true. Set false for an open auction.'), + expiresIn: z.number().optional().describe('RFQ lifetime seconds. Default 300. Hard cap 86400.'), + max_wait_seconds: z.number().optional().describe('How long swap_quote waits for first bids. Default 20, capped 25.'), + client_request_id: z.string().optional().describe('Idempotency key. Same id within this MCP session returns the first result instead of opening a second RFQ. Best-effort: not durable across restarts.'), + }, + wrapTool(async (a) => { + const input = { side: a.side, baseToken: a.baseToken, baseChain: a.baseChain, quoteToken: a.quoteToken, quoteChain: a.quoteChain, amount: a.amount, expiresIn: a.expiresIn, isBlind: a.private ?? true }; + return runSwapQuote(swapClient, a, { + sleep: realSleep, + remember: (op) => idempotency.remember(idempotencyKey('swap_quote', a.client_request_id, input), op), + }); + }), +); + +// ─── swap_status ───────────────────────────────────────────── +server.tool( + 'swap_status', + [ + 'Re-poll an open swap by its swap_handle — returns the current best sealed bid + how many bids are in. Read-only, stateless: the primary way to resume a swap after losing context (only the swap_handle is needed).', + '', + 'USE WHEN: letting maker competition build before executing, or rebuilding an in-flight swap after a context reset. DO NOT USE WHEN: you need settlement-leg detail (use get_htlc) or your trade history (use list_my_trades).', + '', + 'PARAM NOTES: returns best_bid (or null), bids_seen, still_open and rfq_status. When best_bid is present, swap_execute with the same limit_price (or best_bid.quote_id) to take it.', + ].join('\n'), + { + swap_handle: z.string().describe('The swap_handle returned by swap_quote (the RFQ id).'), + max_wait_seconds: z.number().optional().describe('Bounded wait for new bids this call. Default 15, capped 25.'), + }, + wrapTool(async (a) => runSwapStatus(swapClient, a, realSleep)), +); + +// ─── swap_execute ──────────────────────────────────────────── +server.tool( + 'swap_execute', + [ + 'Accept the winning sealed bid for a swap and create the trade. Real funds. Provide EITHER limit_price (auto-takes the best bid only if it meets your bound) OR quote_id (the exact bid you saw via swap_status). With neither, this refuses (CONFIRMATION_REQUIRED) rather than guess — restate the price to the user first.', + '', + 'USE WHEN: a swap has an acceptable bid and the user confirmed. DO NOT USE WHEN: you have not surfaced the price to the user, or you want maker-side quoting (use respond_rfq).', + '', + 'PARAM NOTES: `limit_price` is the sealed reservation (SELL=floor, BUY=ceiling) and must be re-supplied here — it is deliberately never stored. WARNING: accepted_amount may EXCEED your requested amount if a maker quoted a larger size (full-fill v1 accepts a bid whose amount covers the request) — always reconcile accepted_amount against what you asked before settling on-chain. On success returns trade_id; settle on-chain next via create_htlc. This does NOT lock funds itself (non-custodial).', + ].join('\n'), + { + swap_handle: z.string().describe('The swap_handle (RFQ id) from swap_quote.'), + limit_price: z.string().optional().describe('Sealed reservation. SELL=floor, BUY=ceiling. Re-supply it here; never persisted.'), + quote_id: z.string().optional().describe('Exact bid id from swap_status best_bid.quote_id (explicit-confirm path).'), + client_request_id: z.string().optional().describe('Idempotency key. Same id within this session returns the first result instead of accepting twice. Best-effort.'), + }, + wrapTool(async (a) => runSwapExecute(swapClient, a, + (op) => idempotency.remember(idempotencyKey('swap_execute', a.client_request_id, { h: a.swap_handle, q: a.quote_id, l: a.limit_price }), op))), +); + +// ─── swap_cancel ───────────────────────────────────────────── +server.tool( + 'swap_cancel', + [ + 'Abort an open swap before it executes (cancels the underlying RFQ). No funds were locked. Use when the limit never meets, the user changed their mind, or to clean up a stale swap_handle.', + '', + 'USE WHEN: backing out of a swap_quote that has not been executed. DO NOT USE WHEN: the swap already executed (a trade exists) — settlement is governed by the HTLC timelock, not this tool.', + '', + 'PARAM NOTES: idempotent within a session via client_request_id.', + ].join('\n'), + { + swap_handle: z.string().describe('The swap_handle (RFQ id) to cancel.'), + client_request_id: z.string().optional().describe('Idempotency key. Best-effort within this session.'), + }, + wrapTool(async (a) => runSwapCancel(swapClient, a, + (op) => idempotency.remember(idempotencyKey('swap_cancel', a.client_request_id, { h: a.swap_handle }), op))), +); + // ─── Start server ──────────────────────────────────────────── async function main() { diff --git a/src/lib/swap.ts b/src/lib/swap.ts new file mode 100644 index 0000000..3ce8eca --- /dev/null +++ b/src/lib/swap.ts @@ -0,0 +1,308 @@ +import { okContent, type ToolContent } from './result.js'; + +export type Side = 'BUY' | 'SELL'; + +export interface SwapQuote { + id: string; rfqId: string; marketMakerId: string; + price: string; amount: string; + status: string; // QuoteStatus: PENDING|ACCEPTED|REJECTED|EXPIRED + expiresAt?: string | null; createdAt?: string; +} + +export interface SwapRfq { + id: string; side: Side; amount: string; + status: string; // RFQStatus: ACTIVE|QUOTES_RECEIVED|ACCEPTED|FILLED|EXPIRED|CANCELLED + isBlind?: boolean; baseToken?: string; quoteToken?: string; + expiresAt?: string | null; +} + +export interface SwapClient { + createRFQ(input: unknown): Promise<{ id: string; status: string }>; + getRFQ(id: string): Promise; + getQuotes(rfqId: string): Promise; + acceptQuote(quoteId: string): Promise<{ id: string; rfqId: string; status: string; trade?: { id: string; status: string } | null }>; + cancelRFQ(id: string): Promise<{ id: string; status: string }>; +} + +export type Remember = (op: () => Promise) => Promise; +export type Sleep = (ms: number) => Promise; + +const RFQ_OPEN_STATES = new Set(['ACTIVE', 'QUOTES_RECEIVED']); +const SELECTABLE_QUOTE = 'PENDING'; +const POLL_INTERVAL_MS = 2500; +const MAX_WAIT_CAP_S = 25; + +/** Compare two non-negative decimal strings. Returns -1 | 0 | 1. + * String-based (digit-wise) — never parseFloat: token amounts can be + * 18-decimal wei-scale where float loses precision. */ +export function compareDecimal(a: string, b: string): -1 | 0 | 1 { + const norm = (s: string): [string, string] => { + const [intPartRaw, fracRaw = ''] = s.trim().split('.'); + const intPart = intPartRaw.replace(/^0+(?=\d)/, '') || '0'; + return [intPart, fracRaw.replace(/0+$/, '')]; + }; + const [ai, af] = norm(a); + const [bi, bf] = norm(b); + if (ai.length !== bi.length) return ai.length > bi.length ? 1 : -1; + if (ai !== bi) return ai > bi ? 1 : -1; + const fl = Math.max(af.length, bf.length); + const ap = af.padEnd(fl, '0'); + const bp = bf.padEnd(fl, '0'); + if (ap === bp) return 0; + return ap > bp ? 1 : -1; +} + +/** Directional reservation gate. SELL: limit is a FLOOR (accept best >= limit). + * BUY: limit is a CEILING (accept best <= limit). */ +export function limitSatisfied(bestPrice: string, limitPrice: string, side: Side): boolean { + const cmp = compareDecimal(bestPrice, limitPrice); + return side === 'SELL' ? cmp >= 0 : cmp <= 0; +} + +/** Fail-closed money-input grammar: a positive decimal string with no commas, + * no scientific notation, no sign, no bare leading dot. Mirrors the backend + * `positiveAmount` grammar so an ill-formed quote/limit can never be selected. + * compareDecimal stays a total order on well-formed input — this guards it. */ +export function isPositiveDecimal(s: string): boolean { + return /^\d+(\.\d+)?$/.test((s ?? '').trim()); +} + +/** Eligible = PENDING, well-formed price+amount, and amount covers the request + * (full-fill v1). SELL → max price; BUY → min price. null if none. */ +export function selectBestBid(quotes: SwapQuote[], side: Side, requestedAmount: string): SwapQuote | null { + const eligible = quotes.filter( + (x) => x.status === SELECTABLE_QUOTE + && isPositiveDecimal(x.price) && isPositiveDecimal(x.amount) + && compareDecimal(x.amount, requestedAmount) >= 0, + ); + if (eligible.length === 0) return null; + return eligible.reduce((best, x) => { + const c = compareDecimal(x.price, best.price); + if (side === 'SELL') return c > 0 ? x : best; + return c < 0 ? x : best; + }); +} + +/** Poll getQuotes until an eligible bid exists or the bounded window elapses. + * Window clamped to [0, 25]s (kept under the 30s SDK client timeout). + * Clock injected (sleep) so tests are deterministic and instant. */ +export async function pollForQuotes( + client: SwapClient, rfqId: string, side: Side, requestedAmount: string, + maxWaitSeconds: number, sleep: Sleep, +): Promise { + const capped = Math.max(0, Math.min(MAX_WAIT_CAP_S, Math.floor(maxWaitSeconds || 0))); + const iterations = Math.max(1, Math.ceil((capped * 1000) / POLL_INTERVAL_MS) + 1); + let quotes: SwapQuote[] = []; + for (let i = 0; i < iterations; i++) { + quotes = await client.getQuotes(rfqId); + if (selectBestBid(quotes, side, requestedAmount)) return quotes; + if (i < iterations - 1) await sleep(POLL_INTERVAL_MS); + } + return quotes; +} + +export interface SwapQuoteArgs { + side: Side; baseToken: string; baseChain?: string; + quoteToken: string; quoteChain?: string; amount: string; + limit_price?: string; private?: boolean; expiresIn?: number; + max_wait_seconds?: number; client_request_id?: string; +} +export interface SwapDeps { sleep: Sleep; remember: Remember; } + +function bidView(q: SwapQuote | null) { + return q ? { quote_id: q.id, price: q.price, amount: q.amount, expires_at: q.expiresAt ?? null } : null; +} + +export async function runSwapQuote( + client: SwapClient, args: SwapQuoteArgs, deps: SwapDeps, +): Promise { + // Mirror create_rfq EXACTLY: same input object incl. baseChain/quoteChain. + // limit_price is deliberately NOT part of this object (sealed reservation). + const rfqInput = { + baseToken: args.baseToken, baseChain: args.baseChain, + quoteToken: args.quoteToken, quoteChain: args.quoteChain, + side: args.side, amount: args.amount, + expiresIn: args.expiresIn ?? 300, + isBlind: args.private ?? true, + }; + const rfq = await deps.remember(() => client.createRFQ(rfqInput)); + const quotes = await pollForQuotes( + client, rfq.id, args.side, args.amount, args.max_wait_seconds ?? 20, deps.sleep, + ); + const best = selectBestBid(quotes, args.side, args.amount); + // still_open must reflect a POST-poll status: the RFQ can expire during the + // bounded wait when the agent set expiresIn <= the wait window. The caller + // owns this RFQ (just created it) so a forbidden throw is not expected here; + // still, a status-refresh failure is advisory-only and must not break the + // quote response — fall back to the create-time status. + let latestStatus = rfq.status; + try { + const latest = await client.getRFQ(rfq.id); + if (latest) latestStatus = latest.status; + } catch { + // status refresh is advisory-only; fall back to the create-time status + } + return okContent({ + swap_handle: rfq.id, + status: best ? 'QUOTED' : 'OPEN', + best_bid: bidView(best), + bids_seen: quotes.length, + still_open: RFQ_OPEN_STATES.has(latestStatus), + limit_price: args.limit_price ?? null, + next: best + ? 'swap_execute with this swap_handle + your limit_price (or best_bid.quote_id) to take it; or swap_status to let competition build; or swap_cancel to abort.' + : 'No bids yet. Call swap_status to keep waiting, or swap_cancel to abort.', + }); +} + +export interface SwapExecuteArgs { + swap_handle: string; limit_price?: string; quote_id?: string; client_request_id?: string; +} + +export async function runSwapExecute( + client: SwapClient, args: SwapExecuteArgs, remember: Remember, +): Promise { + let rfq: SwapRfq | null; + try { + rfq = await client.getRFQ(args.swap_handle); + } catch (err) { + // Uniform not-found contract: a forbidden/unauthorized RFQ (exists but the + // caller is not a participant) must NOT be distinguishable from a + // non-existent one, or swap_handle becomes an existence/participant oracle. + const msg = err instanceof Error ? err.message : String(err); + if (/forbidden|not a participant|unauthor|\b401\b|\b403\b/i.test(msg)) { + return okContent({ outcome: 'SWAP_NOT_FOUND', swap_handle: args.swap_handle, + next: 'Verify the swap_handle, or open a fresh swap with swap_quote.' }); + } + throw err; + } + if (!rfq) { + return okContent({ outcome: 'SWAP_NOT_FOUND', swap_handle: args.swap_handle, + next: 'Verify the swap_handle, or open a fresh swap with swap_quote.' }); + } + if (!RFQ_OPEN_STATES.has(rfq.status)) { + return okContent({ outcome: 'SWAP_NOT_OPEN', swap_handle: args.swap_handle, rfq_status: rfq.status, + next: 'This swap can no longer be executed. Open a fresh swap with swap_quote.' }); + } + if (args.quote_id && args.limit_price !== undefined) { + return okContent({ outcome: 'INVALID_EXECUTION_PARAMS', swap_handle: args.swap_handle, + next: 'Provide EXACTLY ONE of quote_id (take that exact bid) or limit_price (auto-take best within your bound) — not both. They are distinct confirmation modes; passing both is ambiguous on a real-funds accept.' }); + } + if (!args.quote_id && args.limit_price === undefined) { + return okContent({ outcome: 'CONFIRMATION_REQUIRED', swap_handle: args.swap_handle, + next: 'Real funds. Re-call swap_execute with EITHER limit_price (auto-takes best bid iff it meets your bound) OR quote_id (from swap_status best_bid.quote_id) to confirm the exact price.' }); + } + const quotes = await client.getQuotes(args.swap_handle); + + let chosen: SwapQuote | null; + if (args.quote_id) { + chosen = quotes.find( + (x) => x.id === args.quote_id && x.status === SELECTABLE_QUOTE + && isPositiveDecimal(x.price) && isPositiveDecimal(x.amount) + && compareDecimal(x.amount, rfq.amount) >= 0, + ) ?? null; + if (!chosen) { + return okContent({ outcome: 'QUOTE_NOT_AVAILABLE', swap_handle: args.swap_handle, quote_id: args.quote_id, + next: 'That quote expired or was outbid. Re-check live bids with swap_status.' }); + } + } else { + // limit_price is defined here (the no-args case returned CONFIRMATION_REQUIRED above). + if (!isPositiveDecimal(args.limit_price as string)) { + return okContent({ outcome: 'INVALID_LIMIT_PRICE', swap_handle: args.swap_handle, + limit_price: args.limit_price, + next: 'limit_price must be a positive decimal string like "3450.00" — no commas, no scientific notation, no negative.' }); + } + const best = selectBestBid(quotes, rfq.side, rfq.amount); + if (!best) { + return okContent({ outcome: 'NO_ACCEPTABLE_FILL', swap_handle: args.swap_handle, best_price: null, + limit_price: args.limit_price, side: rfq.side, bids_seen: quotes.length, + next: 'No eligible bids yet. swap_status to wait, or swap_cancel.' }); + } + if (!limitSatisfied(best.price, args.limit_price as string, rfq.side)) { + return okContent({ outcome: 'NO_ACCEPTABLE_FILL', swap_handle: args.swap_handle, best_price: best.price, + limit_price: args.limit_price, side: rfq.side, bids_seen: quotes.length, + next: 'Best bid does not meet your limit. swap_status to wait for better, or swap_cancel.' }); + } + chosen = best; + } + + if (!chosen) throw new Error('invariant: chosen must be set before accept'); + // Idempotency key is composed at the index.ts tool handler (Layer-1 pattern); this fn receives a pre-bound Remember. + const accept = await remember(() => client.acceptQuote(chosen.id)); + return okContent({ + trade_id: accept.trade?.id ?? null, + rfq_id: accept.rfqId, + accepted_price: chosen.price, + accepted_amount: chosen.amount, + status: accept.status, + next: 'Settle on-chain: create_htlc -> get_htlc -> withdraw_htlc (or refund_htlc after timelock).', + }); +} + +export interface SwapCancelArgs { swap_handle: string; client_request_id?: string; } + +export async function runSwapCancel( + client: SwapClient, args: SwapCancelArgs, remember: Remember, +): Promise { + // Idempotency key is composed at the index.ts tool handler (Layer-1 pattern); + // this fn receives a pre-bound Remember. + let res: { id: string; status: string }; + try { + res = await remember(() => client.cancelRFQ(args.swap_handle)); + } catch (err) { + // Uniform not-found contract (parity with runSwapExecute/runSwapStatus): a + // forbidden/unauthorized RFQ (exists but caller is not a participant) must + // NOT be distinguishable from a non-existent one — no existence/participant + // oracle via swap_cancel. + const msg = err instanceof Error ? err.message : String(err); + if (/forbidden|not a participant|unauthor|\b401\b|\b403\b/i.test(msg)) { + return okContent({ outcome: 'SWAP_NOT_FOUND', swap_handle: args.swap_handle, + next: 'Verify the swap_handle, or open a fresh swap with swap_quote.' }); + } + throw err; + } + return okContent({ swap_handle: res.id, status: res.status, + next: 'Swap aborted. No funds were locked. Open a new swap with swap_quote when ready.' }); +} + +export interface SwapStatusArgs { swap_handle: string; max_wait_seconds?: number; } + +export async function runSwapStatus( + client: SwapClient, args: SwapStatusArgs, sleep: Sleep, +): Promise { + let rfq: SwapRfq | null; + try { + rfq = await client.getRFQ(args.swap_handle); + } catch (err) { + // Uniform not-found contract (same as runSwapExecute): a forbidden/unauthorized + // RFQ (exists but caller is not a participant) must NOT be distinguishable from + // a non-existent one, or swap_handle becomes an existence/participant oracle. + const msg = err instanceof Error ? err.message : String(err); + if (/forbidden|not a participant|unauthor|\b401\b|\b403\b/i.test(msg)) { + return okContent({ outcome: 'SWAP_NOT_FOUND', swap_handle: args.swap_handle, + next: 'Verify the swap_handle, or open a fresh swap with swap_quote.' }); + } + throw err; + } + if (!rfq) { + return okContent({ outcome: 'SWAP_NOT_FOUND', swap_handle: args.swap_handle, + next: 'Verify the swap_handle, or open a fresh swap with swap_quote.' }); + } + const open = RFQ_OPEN_STATES.has(rfq.status); + const quotes = open + ? await pollForQuotes(client, rfq.id, rfq.side, rfq.amount, args.max_wait_seconds ?? 15, sleep) + : await client.getQuotes(rfq.id); + const best = selectBestBid(quotes, rfq.side, rfq.amount); + return okContent({ + swap_handle: rfq.id, + status: best ? 'QUOTED' : 'OPEN', + rfq_status: rfq.status, + best_bid: bidView(best), + bids_seen: quotes.length, + still_open: open, + next: best + ? 'swap_execute with this swap_handle + your limit_price (or best_bid.quote_id).' + : open ? 'Still waiting on bids. Call swap_status again, or swap_cancel.' + : 'This swap is closed. Open a fresh one with swap_quote.', + }); +}