From 9218bdd8d808b9c0acd3a39e6d40011cb603e39e Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 17:58:56 -0300 Subject: [PATCH] =?UTF-8?q?fix(swap):=20quote-time=20refund-risk=20boundar?= =?UTF-8?q?ies=20=E2=80=94=20fee/slippage=20gate=20+=20route=20minimums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A $17.76 BTC→ETH swap was refunded on-chain ('Emit asset less than price limit') and came back $14.52 — on small amounts the fixed outbound fee exceeds the slippage allowance, so the emit lands below the memo's limit and the refund burns another outbound fee. Nothing gated this at quote time. Two unit-safe boundaries in getSwapQuote, every route: - fees.totalBps >= slippageBps → throw 'amount too low' (bps are the only fee field with integration-independent units; fees.outbound is base-units-or-decimal depending on swapper). - minAmountIn enforced for ALL integrations — previously only the NEAR Intents block checked it, so THORChain routes sailed past their floor. parseQuoteResponse now also extracts THORChain's recommended_min_amount_in into minAmountIn (always 1e8 base units of the sell asset → converted to decimal; gated to THORChain/Maya routes only, other swappers' units vary). Regression tests pin the units conversion and the non-THORChain skip (51/51). --- .../__tests__/swap-parsing.test.ts | 19 +++++++++++++ .../keepkey-vault/src/bun/swap-parsing.ts | 14 +++++++++- projects/keepkey-vault/src/bun/swap.ts | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/projects/keepkey-vault/__tests__/swap-parsing.test.ts b/projects/keepkey-vault/__tests__/swap-parsing.test.ts index 963ab652..4a80d1d0 100644 --- a/projects/keepkey-vault/__tests__/swap-parsing.test.ts +++ b/projects/keepkey-vault/__tests__/swap-parsing.test.ts @@ -163,6 +163,25 @@ describe('parseQuoteResponse', () => { expect(result.memo).toBe('=:ETH.ETH:0xdest123:245000/3/0:kk:0') }) + test('THORChain recommended_min_amount_in converts 1e8 base units to decimal minAmountIn', () => { + // 27472 sats input got refunded on-chain ("Emit asset less than price + // limit") because nothing enforced THORNode's floor. 50000 base units of + // the sell asset = 0.0005 decimal — a unit slip here (comparing 1e8 + // against a human amount) would make the guard block every swap. + const fixture = JSON.parse(JSON.stringify(FIXTURE_BASE_TO_ETH_QUOTE)) + fixture.data.data[0].quote.raw.recommended_min_amount_in = '50000' + const result = parseQuoteResponse(fixture, baseParams) + expect(result.minAmountIn).toBe('0.0005') + }) + + test('recommended_min_amount_in ignored for non-THORChain swappers (unknown units)', () => { + const fixture = JSON.parse(JSON.stringify(FIXTURE_BASE_TO_ETH_QUOTE)) + fixture.data.data[0].integration = 'relay' + fixture.data.data[0].quote.raw.recommended_min_amount_in = '50000' + const result = parseQuoteResponse(fixture, baseParams) + expect(result.minAmountIn).toBeUndefined() + }) + test('BASE → ETH: extracts router from raw', () => { const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) expect(result.router).toBe('0x1b3e6daa08e7a2e29e2ff23b6c40abe79a15a17a') diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 874ab546..07390d75 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -332,7 +332,19 @@ function parseSingleQuote( // Minimum sell amount — solvers/protocols may refuse amounts below this floor // Check multiple field names across the response layers (Pioneer schema varies by swapper) const minAmountInRaw = quote.minAmountIn ?? best.minAmountIn ?? raw.min_amount_in ?? raw.minAmountIn - const minAmountIn: string | undefined = minAmountInRaw != null ? String(minAmountInRaw) : undefined + let minAmountIn: string | undefined = minAmountInRaw != null ? String(minAmountInRaw) : undefined + // THORChain-family routes publish recommended_min_amount_in — the floor + // below which fixed outbound fees dominate and the chain refunds the swap + // ("Emit asset less than price limit", minus another outbound fee). It is + // ALWAYS 1e8 base units of the sell asset regardless of chain; only trusted + // for THORChain routes — other swappers' units vary, so no generic fallback. + if (!minAmountIn && /thor|maya/i.test(`${integration} ${swapper ?? ''}`)) { + const rec = raw.recommended_min_amount_in ?? quote.recommended_min_amount_in ?? best.recommended_min_amount_in + const recNum = rec != null ? Number(rec) : NaN + if (Number.isFinite(recNum) && recNum > 0) { + minAmountIn = (recNum / 1e8).toFixed(8).replace(/\.?0+$/, '') + } + } // For NEAR Intents ERC-20 routes, Pioneer embeds the 1Click deposit address in // txParams.recipientAddress (same as quote.meta.depositAddress). This is the diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index e7ba029e..c605715f 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -349,6 +349,33 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise : (result.integration || 'unknown') swapLog(`${TAG} Quote: ${result.expectedOutput} (${route}), memo=${result.memo || 'NONE'}, router=${result.router || 'NONE'}, expiry=${result.expiry}`) + // ── Refund-risk boundaries (every route) ────────────────────────────── + // A protocol refunds when the emitted amount lands below the memo's price + // limit — and the refund itself burns another outbound fee (observed: + // $17.76 BTC→ETH came back as $14.52). Two unit-safe gates at quote time: + // + // 1. Fees eating the slippage allowance. totalBps is basis points across + // every integration. If quoted fees >= the slippage tolerance, the swap + // only completes when the price moves in our favor during confirmation — + // on small amounts the fixed outbound fee guarantees a refund instead. + const feeBps = Number(result.fees?.totalBps) || 0 + if (feeBps >= slippageBps) { + throw new Error( + `Swap amount too low: quoted fees (${(feeBps / 100).toFixed(2)}%) meet or exceed the ` + + `slippage allowance (${(slippageBps / 100).toFixed(2)}%), so the protocol would refund ` + + `this swap on-chain — minus another network fee. Increase the amount.` + ) + } + // 2. Route-declared minimum sell amount — enforced for ALL integrations + // (previously only the NEAR Intents block checked it, so THORChain + // routes sailed past their recommended_min_amount_in). + if (result.minAmountIn && parseFloat(params.amount) < parseFloat(result.minAmountIn)) { + throw new Error( + `Amount below this route's minimum (~${result.minAmountIn}). ` + + `Smaller deposits are systematically refunded after fees.` + ) + } + // NEAR Intents: solver-network minimum amount check for ALL source chains. // Solvers must front the destination-chain gas and wait for source confirmations; // amounts below their profitability floor are systematically refunded.