Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions projects/keepkey-vault/__tests__/swap-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
14 changes: 13 additions & 1 deletion projects/keepkey-vault/src/bun/swap-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions projects/keepkey-vault/src/bun/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,33 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise<SwapQuote>
: (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.
Expand Down