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
38 changes: 37 additions & 1 deletion projects/keepkey-vault/__tests__/swap-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────

Expand Down Expand Up @@ -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 }
Expand Down
30 changes: 29 additions & 1 deletion projects/keepkey-vault/src/bun/swap-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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
Expand Down
21 changes: 8 additions & 13 deletions projects/keepkey-vault/src/bun/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]'

Expand Down Expand Up @@ -110,12 +115,6 @@ const DEPOSIT_GAS_LIMITS: Record<string, bigint> = {
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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down