diff --git a/projects/keepkey-vault/__tests__/swap-parsing.test.ts b/projects/keepkey-vault/__tests__/swap-parsing.test.ts index d0b3aa76..4e1fe1bb 100644 --- a/projects/keepkey-vault/__tests__/swap-parsing.test.ts +++ b/projects/keepkey-vault/__tests__/swap-parsing.test.ts @@ -7,7 +7,11 @@ * Run: bun test __tests__/swap-parsing.test.ts */ import { describe, test, expect } from 'bun:test' -import { parseQuoteResponse, parseAssetsResponse } from '../src/bun/swap-parsing' +import { + assertSwapMemoFitsSource, + parseQuoteResponse, + parseAssetsResponse, +} from '../src/bun/swap-parsing' // ── Fixtures: Real Pioneer SDK response shapes ────────────────────── @@ -325,6 +329,38 @@ describe('parseQuoteResponse', () => { expect(parseFloat(result.minimumOutput)).toBeCloseTo(1.239375, 4) }) + test('BTC → USDT: skips an overlong full-contract memo for a compact route', () => { + const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + const usdtCaip = 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' + const destination = `0x${'a'.repeat(40)}` + const fullMemo = `=:ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7:${destination}:323592379572:keep:30` + const compactMemo = `=:ETH.USDT:${destination}:323592379572:keep:30` + const quote = (memo: string, buyAmount: string) => ({ + integration: 'thorchain', + quote: { + buyAmount, + raw: { inbound_address: 'bc1qvaultaddress' }, + txs: [{ txParams: { memo, vaultAddress: 'bc1qvaultaddress' } }], + }, + }) + + expect(Buffer.byteLength(fullMemo, 'utf8')).toBeGreaterThan(80) + expect(Buffer.byteLength(compactMemo, 'utf8')).toBeLessThanOrEqual(80) + const result = parseQuoteResponse( + { data: [quote(fullMemo, '100'), quote(compactMemo, '99')] }, + { fromCaip: btcCaip, toCaip: usdtCaip, slippageBps: 300 }, + ) + expect(result.memo).toBe(compactMemo) + expect(result.expectedOutput).toBe('99') + }) + + test('UTXO memo limit is measured in bytes, not JavaScript characters', () => { + const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + expect(() => assertSwapMemoFitsSource('a'.repeat(80), btcCaip)).not.toThrow() + expect(() => assertSwapMemoFitsSource('é'.repeat(41), btcCaip)) + .toThrow(/82 bytes; maximum 80/) + }) + // Minimal response (fields at top-level quote, no raw/txs) test('minimal: extracts fields from top-level quote properties', () => { const params = { fromCaip: 'eip155:1/slip44:60', toCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', slippageBps: 300 } diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 513374e3..71bfb530 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -10,6 +10,32 @@ import { COIN_MAP_LONG } from '@pioneer-platform/pioneer-coins' const TAG = '[swap]' +/** THORChain accepts up to 250 bytes globally, but UTXO deposits encode the + * memo in a single OP_RETURN output and the KeepKey signer supports 80 bytes. + * Keep this source-aware so an otherwise valid quote cannot survive preview + * and then fail only after the user confirms on-device. */ +export const GLOBAL_SWAP_MEMO_MAX_BYTES = 250 +export const UTXO_SWAP_MEMO_MAX_BYTES = 80 + +export function swapMemoLimitForSource(fromCaip: string): number { + return fromCaip.startsWith('bip122:') + ? UTXO_SWAP_MEMO_MAX_BYTES + : GLOBAL_SWAP_MEMO_MAX_BYTES +} + +export function assertSwapMemoFitsSource(memo: string, fromCaip: string): void { + if (!memo) return + const byteLength = Buffer.byteLength(memo, 'utf8') + const limit = swapMemoLimitForSource(fromCaip) + if (byteLength <= limit) return + + const source = fromCaip.startsWith('bip122:') ? 'UTXO source chain' : 'swap protocol' + throw new Error( + `Swap route memo is too long for this ${source} ` + + `(${byteLength} bytes; maximum ${limit}). Refresh the quote or choose another route.`, + ) +} + function decodeStrictBase64(value: unknown, field: string, maxBytes: number): string { if (typeof value !== 'string' || value.length === 0) { throw new Error(`Invalid Solana ClearSign metadata: missing ${field}`) @@ -175,7 +201,8 @@ export function parseQuoteResponse( /** Parse ONE Pioneer quote entry into SwapQuote. Throws when the quote is not * buildable (zero/empty output, missing inbound address, EVM inbound for a - * UTXO source, or no memo/calldata/deposit instruction). */ + * UTXO source, source-chain memo overflow, or no memo/calldata/deposit + * instruction). */ function parseSingleQuote( best: any, params: { fromCaip: string; toCaip: string; slippageBps?: number }, @@ -301,6 +328,7 @@ function parseSingleQuote( // Memo lives in txParams (Pioneer constructs it), fallback to raw // Relay quotes don't use memos — the calldata IS the swap instruction const memo = txParams.memo || quote.memo || raw.memo || '' + assertSwapMemoFitsSource(memo, params.fromCaip) // Router: raw.router or txParams.recipientAddress (Pioneer sets recipient = router for EVM) const router = raw.router || quote.router || txParams.recipientAddress // Vault/inbound address — check both snake_case and camelCase across all layers diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 98c8beeb..4e76cfe0 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -22,7 +22,12 @@ import { normalizeBchAddress } from './txbuilder' // history rows without CAIP). The swap quote/execute path no longer uses it — // vault is CAIP-native end-to-end. export { parseAssetsResponse, parseQuoteResponse, assetToCaip } from './swap-parsing' -import { parseQuoteResponse, parseAssetsResponse, isNativeDepositCaip } from './swap-parsing' +import { + assertSwapMemoFitsSource, + parseQuoteResponse, + parseAssetsResponse, + isNativeDepositCaip, +} from './swap-parsing' const TAG = '[swap]' @@ -110,12 +115,6 @@ const DEPOSIT_GAS_LIMITS: Record = { optimism: 200000n, } -/** Memo length limits — THORChain global limit is 250 bytes. - * THORNode constructs memos optimized for source chain constraints (e.g. short - * asset names like AVAX.USDT instead of AVAX.USDT-0x...) so we trust the memo - * from Pioneer/THORNode and only enforce the THORChain protocol limit. */ -const MEMO_LIMIT = 250 - // Router/inbound-address validation belongs in pioneer-router (which runs // each integration's own checks before returning a quote). The vault trusts // the router string Pioneer hands back. Kept here as a comment so future @@ -630,12 +629,7 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): const isMemolessTransfer = (fromIsUtxo || fromIsSolana) && !!params.inboundAddress && !params.memo if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error('Missing inbound vault address from quote') if (!params.memo && !hasPrebuiltTx && !isMemolessTransfer) throw new Error('Missing swap memo from quote') - if (params.memo) { - const memoByteLength = Buffer.byteLength(params.memo, 'utf8') - if (memoByteLength > MEMO_LIMIT) { - throw new Error(`Swap memo too long (${memoByteLength} bytes, THORChain max ${MEMO_LIMIT})`) - } - } + assertSwapMemoFitsSource(params.memo, params.fromCaip) swapLog(`${TAG} Executing: ${params.fromCaip} → ${params.toCaip}, amount=${params.amount}`) if (hasPrebuiltTx) { @@ -1005,6 +999,7 @@ export async function previewSwapBuild( const isMemolessTransfer = (fromIsUtxoPreview || fromIsSolanaPreview) && !!params.inboundAddress && !params.memo if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error('Missing inbound vault address from quote') if (!params.memo && !hasPrebuiltTx && !isMemolessTransfer) throw new Error('Missing swap memo from quote') + assertSwapMemoFitsSource(params.memo, params.fromCaip) const pioneer = await getPioneer() diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index f2da72f6..749e60db 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -2240,6 +2240,8 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, friendly = t("approvalReverted", "Approval reverted on-chain — swap aborted to protect funds") } else if (/Insufficient.*for gas|insufficient funds/i.test(raw)) { friendly = t("insufficientGas", "Not enough gas in the source chain's native asset") + } else if (/OP_RETURN.*(?:character count|too .* high)|memo is too long/i.test(raw)) { + friendly = t("swapMemoTooLong", "This route returned a memo that is too long for the source chain. Refresh the quote or choose another route.") } else if (/nonce|fetch nonce/i.test(raw)) { friendly = t("nonceError", "Network error fetching nonce — retry in a moment") } else if (/timed out|ETIMEDOUT|fetch failed|network/i.test(raw)) {