From 58a2d89597c7727d913774f05a939dcfa899efbf Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 23:29:20 +0300 Subject: [PATCH 01/12] feat(swap): decimal compare + directional limit gate --- src/__tests__/swap.test.ts | 26 +++++++++++++++++ src/lib/swap.ts | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/__tests__/swap.test.ts create mode 100644 src/lib/swap.ts diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts new file mode 100644 index 0000000..fa18eea --- /dev/null +++ b/src/__tests__/swap.test.ts @@ -0,0 +1,26 @@ +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); + }); +}); diff --git a/src/lib/swap.ts b/src/lib/swap.ts new file mode 100644 index 0000000..a56c770 --- /dev/null +++ b/src/lib/swap.ts @@ -0,0 +1,58 @@ +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; +} From d709b2403d0fc3412a16777a31eb6b20ac12302b Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 23:34:36 +0300 Subject: [PATCH 02/12] feat(swap): selectBestBid eligibility + per-side extreme --- src/__tests__/swap.test.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/lib/swap.ts | 14 +++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index fa18eea..c21fdb3 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -24,3 +24,46 @@ describe('limitSatisfied (directional — SELL floor, BUY ceiling)', () => { 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(); + }); +}); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index a56c770..d871d50 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -56,3 +56,17 @@ export function limitSatisfied(bestPrice: string, limitPrice: string, side: Side const cmp = compareDecimal(bestPrice, limitPrice); return side === 'SELL' ? cmp >= 0 : cmp <= 0; } + +/** Eligible = PENDING 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 && 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; + }); +} From 4b45dcbe91706b78392d366a6c41e3b5a2e87c73 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 23:40:27 +0300 Subject: [PATCH 03/12] feat(swap): bounded pollForQuotes with injected clock --- src/__tests__/swap.test.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/lib/swap.ts | 18 ++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index c21fdb3..aea057e 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -67,3 +67,46 @@ describe('selectBestBid', () => { expect(selectBestBid([q({ amount: '1' })], 'SELL', '10')).toBeNull(); }); }); + +import { pollForQuotes, type SwapClient } 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).toBeGreaterThanOrEqual(2); + }); + 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); + const capped = await (async () => calls)(); + expect(calls).toBeLessThanOrEqual(11); + }); +}); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index d871d50..f797902 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -70,3 +70,21 @@ export function selectBestBid(quotes: SwapQuote[], side: Side, requestedAmount: 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; +} From bd79144512160f9aafa0778fcdc449158f03e3c6 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 23:47:36 +0300 Subject: [PATCH 04/12] feat(swap): runSwapQuote (open + bounded poll + sealed reservation) --- src/__tests__/swap.test.ts | 48 +++++++++++++++++++++++++++++++++++++- src/lib/swap.ts | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index aea057e..7edb9f8 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -68,7 +68,8 @@ describe('selectBestBid', () => { }); }); -import { pollForQuotes, type SwapClient } from '../lib/swap.js'; +import { pollForQuotes, type SwapClient, runSwapQuote } from '../lib/swap.js'; +import type { Remember } from '../lib/swap.js'; function fakeClient(over: Partial): SwapClient { return { @@ -110,3 +111,48 @@ describe('pollForQuotes', () => { 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); + }); + + 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); + }); +}); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index f797902..ee02b6a 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -1,3 +1,5 @@ +import { okContent, type ToolContent } from './result.js'; + export type Side = 'BUY' | 'SELL'; export interface SwapQuote { @@ -88,3 +90,45 @@ export async function pollForQuotes( } 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); + return okContent({ + swap_handle: rfq.id, + status: best ? 'QUOTED' : 'OPEN', + best_bid: bidView(best), + bids_seen: quotes.length, + still_open: RFQ_OPEN_STATES.has(rfq.status), + 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.', + }); +} From 78b0ba778b118545c254c79448a7dfb10224be2c Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 23:54:08 +0300 Subject: [PATCH 05/12] feat(swap): runSwapExecute status-gate + branch + sealed-limit accept --- src/__tests__/swap.test.ts | 59 ++++++++++++++++++++++++++++++++++++++ src/lib/swap.ts | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index 7edb9f8..3f47796 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -156,3 +156,62 @@ describe('runSwapQuote', () => { expect(createInput.isBlind).toBe(false); }); }); + +import { runSwapExecute } 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' }) }); + 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 }); + const out = parse(await runSwapExecute(client, { swap_handle: 'nope', limit_price: '1' }, passthrough)); + expect(out.outcome).toBe('SWAP_NOT_FOUND'); + }); +}); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index ee02b6a..6ec5c1c 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -132,3 +132,60 @@ export async function runSwapQuote( : '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 { + const rfq = await client.getRFQ(args.swap_handle); + 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.' }); + } + 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 + && 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 if (args.limit_price !== undefined) { + 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, 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; + } else { + 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 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).', + }); +} From 7f7143f9c601225cafa904e96b707dbced707a40 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 00:10:16 +0300 Subject: [PATCH 06/12] fix(swap): uniform not-found on forbidden RFQ + fail-closed price validation + invariant guard + flow order (review) --- src/__tests__/swap.test.ts | 88 ++++++++++++++++++++++++++++++++++++-- src/lib/swap.ts | 53 ++++++++++++++++++----- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index 3f47796..ca73a6d 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -157,7 +157,7 @@ describe('runSwapQuote', () => { }); }); -import { runSwapExecute } from '../lib/swap.js'; +import { runSwapExecute, isPositiveDecimal } from '../lib/swap.js'; const SELL_RFQ = { id: 'r1', side: 'SELL' as const, amount: '10', status: 'QUOTES_RECEIVED' }; const pendingBids = [ @@ -204,14 +204,96 @@ describe('runSwapExecute', () => { 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' }) }); + 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 }); + 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(); + }); + + // 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'); + }); }); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index 6ec5c1c..7004b70 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -59,11 +59,21 @@ export function limitSatisfied(bestPrice: string, limitPrice: string, side: Side return side === 'SELL' ? cmp >= 0 : cmp <= 0; } -/** Eligible = PENDING and amount covers the request (full-fill v1). - * SELL → max price; BUY → min price. null if none. */ +/** 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 && compareDecimal(x.amount, requestedAmount) >= 0, + (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) => { @@ -140,7 +150,20 @@ export interface SwapExecuteArgs { export async function runSwapExecute( client: SwapClient, args: SwapExecuteArgs, remember: Remember, ): Promise { - const rfq = await client.getRFQ(args.swap_handle); + 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.' }); @@ -149,37 +172,47 @@ export async function runSwapExecute( 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: '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 if (args.limit_price !== undefined) { + } 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, rfq.side)) { + 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; - } else { - 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 accept = await remember(() => client.acceptQuote(chosen!.id)); + 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, From 5191ae15922d48535b66836267eb3faf6681e1cf Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 00:17:36 +0300 Subject: [PATCH 07/12] feat(swap): runSwapStatus stateless re-poll --- src/__tests__/swap.test.ts | 27 +++++++++++++++++++++++++++ src/lib/swap.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index ca73a6d..607dcf3 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -297,3 +297,30 @@ describe('runSwapExecute', () => { expect(out.accepted_price).toBe('3400'); }); }); + +import { runSwapStatus } 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'); + }); +}); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index 7004b70..a6961d0 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -222,3 +222,32 @@ export async function runSwapExecute( next: 'Settle on-chain: create_htlc -> get_htlc -> withdraw_htlc (or refund_htlc after timelock).', }); } + +export interface SwapStatusArgs { swap_handle: string; max_wait_seconds?: number; } + +export async function runSwapStatus( + client: SwapClient, args: SwapStatusArgs, sleep: Sleep, +): Promise { + const rfq = await client.getRFQ(args.swap_handle); + 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.', + }); +} From 81fb93a7a467610920d3d12f9a24bea2e500467d Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 00:23:42 +0300 Subject: [PATCH 08/12] fix(swap): runSwapStatus uniform not-found on forbidden RFQ (F1 parity) --- src/__tests__/swap.test.ts | 15 +++++++++++++++ src/lib/swap.ts | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index 607dcf3..d4682ee 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -322,5 +322,20 @@ describe('runSwapStatus', () => { 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'); }); }); diff --git a/src/lib/swap.ts b/src/lib/swap.ts index a6961d0..0d4dcd0 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -228,7 +228,20 @@ export interface SwapStatusArgs { swap_handle: string; max_wait_seconds?: number export async function runSwapStatus( client: SwapClient, args: SwapStatusArgs, sleep: Sleep, ): Promise { - const rfq = await client.getRFQ(args.swap_handle); + 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.' }); From 97d1907db3b00ccdb430ccd4bdf8f6da50cf75a5 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 00:27:40 +0300 Subject: [PATCH 09/12] feat(swap): runSwapCancel + uniform not-found on forbidden RFQ (F1 parity) --- src/__tests__/swap.test.ts | 22 +++++++++++++++++++++- src/lib/swap.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index d4682ee..47a61d8 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -298,7 +298,7 @@ describe('runSwapExecute', () => { }); }); -import { runSwapStatus } from '../lib/swap.js'; +import { runSwapStatus, runSwapCancel } from '../lib/swap.js'; describe('runSwapStatus', () => { it('reconstructs swap state from the handle alone', async () => { @@ -339,3 +339,23 @@ describe('runSwapStatus', () => { 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/lib/swap.ts b/src/lib/swap.ts index 0d4dcd0..5db731b 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -223,6 +223,32 @@ export async function runSwapExecute( }); } +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( From fc052f3b24d59dae9120ae79644b06bbd7af4761 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 00:37:04 +0300 Subject: [PATCH 10/12] feat(swap): register swap_quote/status/execute/cancel tools + pin descriptions --- src/__tests__/swap.test.ts | 3 +- src/__tests__/tools.test.ts | 34 +++++++++++++ src/index.ts | 99 ++++++++++++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index 47a61d8..0278475 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -101,13 +101,12 @@ describe('pollForQuotes', () => { const client = fakeClient({ getQuotes: async () => { calls += 1; return []; } }); const quotes = await pollForQuotes(client, 'r1', 'SELL', '10', 20, noSleep); expect(quotes).toEqual([]); - expect(calls).toBeGreaterThanOrEqual(2); + 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); - const capped = await (async () => calls)(); expect(calls).toBeLessThanOrEqual(11); }); }); 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() { From fd0837d363fdcb4d49b8e13515b107423bf532db Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 00:44:27 +0300 Subject: [PATCH 11/12] chore: v0.4.0 (npm) + registry manifest-rev 1.4.0 --- package.json | 2 +- server.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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": [ From 14b1d5fd13fba07d0c992e77792c3535e4b1893e Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Tue, 19 May 2026 13:01:28 +0300 Subject: [PATCH 12/12] fix(swap): reject ambiguous swap_execute (quote_id+limit_price); accurate still_open post-poll (CodeRabbit PR#7) --- src/__tests__/swap.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/lib/swap.ts | 18 ++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/__tests__/swap.test.ts b/src/__tests__/swap.test.ts index 0278475..c688fd7 100644 --- a/src/__tests__/swap.test.ts +++ b/src/__tests__/swap.test.ts @@ -147,6 +147,46 @@ describe('runSwapQuote', () => { 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 () => [] }); @@ -279,6 +319,20 @@ describe('runSwapExecute', () => { 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; diff --git a/src/lib/swap.ts b/src/lib/swap.ts index 5db731b..3ce8eca 100644 --- a/src/lib/swap.ts +++ b/src/lib/swap.ts @@ -130,12 +130,24 @@ export async function runSwapQuote( 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(rfq.status), + 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.' @@ -172,6 +184,10 @@ export async function runSwapExecute( 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.' });