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.