diff --git a/modules/hdwallet b/modules/hdwallet index 9d6ed95..87e594b 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 9d6ed95ea30f2dbdb2a08adf605982fdf0c99ea1 +Subproject commit 87e594b7845f779c344c535bb67ef323d88cd1bf diff --git a/projects/keepkey-vault/__tests__/swap-parsing.test.ts b/projects/keepkey-vault/__tests__/swap-parsing.test.ts new file mode 100644 index 0000000..eaa2f89 --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-parsing.test.ts @@ -0,0 +1,360 @@ +/** + * Tests for Pioneer SDK response parsing in swap.ts + * + * These test the pure parsing functions (parseQuoteResponse, parseAssetsResponse) + * against real Pioneer response fixtures to catch field extraction regressions. + * + * Run: bun test __tests__/swap-parsing.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { parseQuoteResponse, parseAssetsResponse } from '../src/bun/swap-parsing' + +// ── Fixtures: Real Pioneer SDK response shapes ────────────────────── + +/** BASE → ETH swap via Pioneer (THORChain integration) */ +const FIXTURE_BASE_TO_ETH_QUOTE = { + data: { + success: true, + data: [{ + integration: 'thorchain', + quote: { + buyAmount: '0.00245', + amountOutMin: '0.00238', + inbound_address: null, + router: null, + memo: null, + raw: { + inbound_address: '0xabc123vault', + router: '0x1b3e6daa08e7a2e29e2ff23b6c40abe79a15a17a', + expected_amount_out: '0.00245', + expiry: 1710000000, + fees: { + total_bps: 150, + outbound: '0.0001', + affiliate: '0.00005', + slippage_bps: 42, + }, + warning: 'Streaming swap: may take longer', + inbound_confirmation_seconds: 120, + }, + txs: [{ + txParams: { + memo: '=:ETH.ETH:0xdest123:245000/3/0:kk:0', + recipientAddress: '0x1b3e6daa08e7a2e29e2ff23b6c40abe79a15a17a', + vaultAddress: '0xabc123vault', + }, + }], + }, + }], + }, +} + +/** BTC → ETH swap — Pioneer wraps THORNode data differently */ +const FIXTURE_BTC_TO_ETH_QUOTE = { + data: [{ + integration: 'thorchain', + quote: { + buyAmount: '1.25', + raw: { + inbound_address: 'bc1qvaultaddress', + router: undefined, + expected_amount_out: '1.25', + expiry: 0, + fees: { + total_bps: 200, + outbound: '0.001', + affiliate: '0', + slippage_bps: 85, + }, + total_swap_seconds: 900, + }, + txs: [{ + txParams: { + memo: '=:ETH.ETH:0xdest456:125000', + vaultAddress: 'bc1qvaultaddress', + }, + }], + }, + }], +} + +/** Minimal quote response — fields at top level, no raw/txs nesting */ +const FIXTURE_MINIMAL_QUOTE = { + data: { + data: [{ + integration: 'shapeshift', + quote: { + buyAmount: '500', + memo: 'swap:ETH.ETH:0xdest', + inbound_address: '0xvault789', + router: '0xrouter789', + expiry: 1710000001, + fees: { + totalBps: 100, + outbound: '0.05', + affiliate: '0.01', + slippageBps: 50, + }, + estimatedTime: 300, + }, + }], + }, +} + +/** Quote response where data is a single object, not array */ +const FIXTURE_SINGLE_QUOTE = { + data: { + integration: 'chainflip', + quote: { + buyAmount: '0.5', + inbound_address: '0xsingle_vault', + memo: 'cf:swap', + fees: { + totalBps: 75, + outbound: '0.002', + affiliate: '0', + }, + estimatedTime: 180, + }, + }, +} + +/** Assets response from Pioneer GetAvailableAssets */ +const FIXTURE_ASSETS_RESPONSE = { + data: { + success: true, + data: { + assets: [ + { asset: 'BTC.BTC', symbol: 'BTC', name: 'Bitcoin', decimals: 8 }, + { asset: 'ETH.ETH', symbol: 'ETH', name: 'Ethereum', decimals: 18 }, + { asset: 'ETH.USDT-0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', name: 'Tether USD', decimals: 6 }, + { asset: 'GAIA.ATOM', symbol: 'ATOM', name: 'Cosmos Hub', decimals: 6 }, + { asset: 'BASE.ETH', symbol: 'ETH', name: 'Base ETH', decimals: 18 }, + { asset: 'UNKNOWN.FOO', symbol: 'FOO' }, // unknown chain — should be filtered out + ], + }, + }, +} + +/** Assets response with flat array (no wrapper) */ +const FIXTURE_ASSETS_FLAT = { + data: [ + { asset: 'BTC.BTC', symbol: 'BTC', name: 'Bitcoin' }, + { asset: 'ETH.ETH', symbol: 'ETH', name: 'Ethereum' }, + ], +} + +// ── Quote parsing tests ───────────────────────────────────────────── + +describe('parseQuoteResponse', () => { + const baseParams = { fromAsset: 'BASE.ETH', toAsset: 'ETH.ETH', slippageBps: 300 } + + test('BASE → ETH: extracts memo from txParams', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.memo).toBe('=:ETH.ETH:0xdest123:245000/3/0:kk:0') + }) + + test('BASE → ETH: extracts router from raw', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.router).toBe('0x1b3e6daa08e7a2e29e2ff23b6c40abe79a15a17a') + }) + + test('BASE → ETH: extracts inboundAddress from raw', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.inboundAddress).toBe('0xabc123vault') + }) + + test('BASE → ETH: extracts expiry from raw', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.expiry).toBe(1710000000) + }) + + test('BASE → ETH: extracts expectedOutput', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.expectedOutput).toBe('0.00245') + }) + + test('BASE → ETH: extracts fees from raw.fees', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.fees.totalBps).toBe(150) + expect(result.fees.outbound).toBe('0.0001') + expect(result.fees.affiliate).toBe('0.00005') + }) + + test('BASE → ETH: extracts slippageBps from raw.fees', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.slippageBps).toBe(42) + }) + + test('BASE → ETH: extracts warning from raw', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.warning).toBe('Streaming swap: may take longer') + }) + + test('BASE → ETH: extracts estimatedTime from raw', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.estimatedTime).toBe(120) + }) + + test('BASE → ETH: extracts integration', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.integration).toBe('thorchain') + }) + + test('BASE → ETH: minimumOutput from amountOutMin', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.minimumOutput).toBe('0.00238') + }) + + test('BASE → ETH: preserves fromAsset/toAsset from params', () => { + const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) + expect(result.fromAsset).toBe('BASE.ETH') + expect(result.toAsset).toBe('ETH.ETH') + }) + + // BTC → ETH (no router, memo in txParams) + test('BTC → ETH: extracts memo from txParams', () => { + const params = { fromAsset: 'BTC.BTC', toAsset: 'ETH.ETH', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_BTC_TO_ETH_QUOTE, params) + expect(result.memo).toBe('=:ETH.ETH:0xdest456:125000') + }) + + test('BTC → ETH: inboundAddress from txParams.vaultAddress', () => { + const params = { fromAsset: 'BTC.BTC', toAsset: 'ETH.ETH', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_BTC_TO_ETH_QUOTE, params) + expect(result.inboundAddress).toBe('bc1qvaultaddress') + }) + + test('BTC → ETH: router is undefined (UTXO chains have no router)', () => { + const params = { fromAsset: 'BTC.BTC', toAsset: 'ETH.ETH', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_BTC_TO_ETH_QUOTE, params) + expect(result.router).toBeUndefined() + }) + + test('BTC → ETH: estimatedTime from raw.total_swap_seconds', () => { + const params = { fromAsset: 'BTC.BTC', toAsset: 'ETH.ETH', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_BTC_TO_ETH_QUOTE, params) + expect(result.estimatedTime).toBe(900) + }) + + test('BTC → ETH: minimumOutput calculated from slippage when no amountOutMin', () => { + const params = { fromAsset: 'BTC.BTC', toAsset: 'ETH.ETH', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_BTC_TO_ETH_QUOTE, params) + // 1.25 * (1 - 85/10000) = 1.25 * 0.9915 = 1.239375 + expect(parseFloat(result.minimumOutput)).toBeCloseTo(1.239375, 4) + }) + + // Minimal response (fields at top-level quote, no raw/txs) + test('minimal: extracts fields from top-level quote properties', () => { + const params = { fromAsset: 'ETH.ETH', toAsset: 'BTC.BTC', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_MINIMAL_QUOTE, params) + expect(result.memo).toBe('swap:ETH.ETH:0xdest') + expect(result.inboundAddress).toBe('0xvault789') + expect(result.router).toBe('0xrouter789') + expect(result.expiry).toBe(1710000001) + expect(result.expectedOutput).toBe('500') + expect(result.estimatedTime).toBe(300) + expect(result.integration).toBe('shapeshift') + }) + + // Single object (not array) + test('single object response: wraps in array and parses', () => { + const params = { fromAsset: 'ETH.ETH', toAsset: 'BTC.BTC', slippageBps: 300 } + const result = parseQuoteResponse(FIXTURE_SINGLE_QUOTE, params) + expect(result.expectedOutput).toBe('0.5') + expect(result.memo).toBe('cf:swap') + expect(result.inboundAddress).toBe('0xsingle_vault') + expect(result.integration).toBe('chainflip') + }) + + // Error cases + test('throws on empty response', () => { + expect(() => parseQuoteResponse(null, baseParams)) + .toThrow('Pioneer Quote returned empty response') + }) + + test('throws on missing output amount', () => { + const badResp = { data: [{ quote: { inbound_address: '0x123' } }] } + expect(() => parseQuoteResponse(badResp, baseParams)) + .toThrow('Quote response missing output amount') + }) + + test('throws on missing inbound address', () => { + const badResp = { data: [{ quote: { buyAmount: '1.0' } }] } + expect(() => parseQuoteResponse(badResp, baseParams)) + .toThrow('Quote response missing inbound address') + }) +}) + +// ── Assets parsing tests ──────────────────────────────────────────── + +describe('parseAssetsResponse', () => { + test('parses double-wrapped response with assets array', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + expect(assets.length).toBe(5) // 5 known chains, 1 unknown filtered + }) + + test('maps BTC.BTC to bitcoin chain', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const btc = assets.find(a => a.asset === 'BTC.BTC') + expect(btc).toBeTruthy() + expect(btc!.chainId).toBe('bitcoin') + expect(btc!.symbol).toBe('BTC') + expect(btc!.chainFamily).toBe('utxo') + }) + + test('maps ETH.ETH to ethereum chain', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const eth = assets.find(a => a.asset === 'ETH.ETH') + expect(eth).toBeTruthy() + expect(eth!.chainId).toBe('ethereum') + expect(eth!.chainFamily).toBe('evm') + }) + + test('extracts ERC-20 contract address', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const usdt = assets.find(a => a.asset.startsWith('ETH.USDT')) + expect(usdt).toBeTruthy() + expect(usdt!.contractAddress).toBe('0xdAC17F958D2ee523a2206206994597C13D831ec7') + expect(usdt!.decimals).toBe(6) + }) + + test('maps GAIA.ATOM to cosmos chain', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const atom = assets.find(a => a.asset === 'GAIA.ATOM') + expect(atom).toBeTruthy() + expect(atom!.chainId).toBe('cosmos') + expect(atom!.chainFamily).toBe('cosmos') + }) + + test('maps BASE.ETH to base chain', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const base = assets.find(a => a.asset === 'BASE.ETH') + expect(base).toBeTruthy() + expect(base!.chainId).toBe('base') + expect(base!.chainFamily).toBe('evm') + }) + + test('filters out unknown chains', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const unknown = assets.find(a => a.asset === 'UNKNOWN.FOO') + expect(unknown).toBeUndefined() + }) + + test('parses flat array response (single unwrap)', () => { + const assets = parseAssetsResponse(FIXTURE_ASSETS_FLAT) + expect(assets.length).toBe(2) + expect(assets[0].asset).toBe('BTC.BTC') + expect(assets[1].asset).toBe('ETH.ETH') + }) + + test('throws on empty response', () => { + expect(() => parseAssetsResponse(null)) + .toThrow('Pioneer GetAvailableAssets returned empty response') + }) + + test('throws on non-array response', () => { + expect(() => parseAssetsResponse({ data: { data: 'not-an-array' } })) + .toThrow('unexpected response shape') + }) +}) diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index a04dce2..ff945de 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -23,6 +23,7 @@ export default { "node-hid", "usb", "ethers", + "@pioneer-platform/pioneer-client", ], }, // Vite builds to dist/, we copy from there diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index f14e9c2..a2cf113 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -32,12 +32,12 @@ "i18next-resources-to-backend": "^1.2.1", "jsqr": "^1.4.0", "node-hid": "^3.3.0", + "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-countup": "^6.5.3", "react-dom": "^18.3.1", "react-i18next": "^16.5.4", "react-icons": "^5.5.0", - "pdf-lib": "^1.17.1", "usb": "^2.17.0", "zod": "^4.3.6" }, diff --git a/projects/keepkey-vault/scripts/collect-externals.ts b/projects/keepkey-vault/scripts/collect-externals.ts index 1bc47f4..244f42c 100644 --- a/projects/keepkey-vault/scripts/collect-externals.ts +++ b/projects/keepkey-vault/scripts/collect-externals.ts @@ -19,6 +19,7 @@ const EXTERNALS = [ 'node-hid', 'usb', 'ethers', + '@pioneer-platform/pioneer-client', ] const projectRoot = join(import.meta.dir, '..') @@ -267,32 +268,37 @@ if (nestedCount > 0) { } // Prune unnecessary files to reduce bundle size -const PRUNE_PATTERNS = [ - // Docs & metadata +// SAFE_PRUNE: can be removed anywhere in the tree (files/config that are never runtime code) +const SAFE_PRUNE = new Set([ 'README.md', 'readme.md', 'README', 'CHANGELOG.md', 'CHANGELOG', 'HISTORY.md', - 'LICENSE', 'LICENSE.md', 'LICENSE.txt', 'license', 'LICENCE', 'LICENCE.md', + 'LICENSE.md', 'LICENSE.txt', 'LICENCE.md', 'CONTRIBUTING.md', '.npmignore', '.eslintrc', '.eslintrc.js', '.eslintrc.json', '.prettierrc', '.prettierrc.js', '.editorconfig', '.travis.yml', '.github', 'tsconfig.json', 'tsconfig.tsbuildinfo', '.babelrc', 'babel.config.js', 'jest.config.js', 'jest.config.ts', 'karma.conf.js', '.nyc_output', - 'coverage', 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'AUTHORS', - // Test directories - 'test', 'tests', '__tests__', '__mocks__', 'spec', 'benchmark', 'benchmarks', - // NOTE: Do NOT prune 'src' — many packages (bip32, etc.) use src/ as their main entry point - // TypeScript source maps - '*.map', -] + 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'AUTHORS', +]) + +// ROOT_ONLY_PRUNE: directories that should ONLY be pruned at a package root (direct child +// of a dir with package.json). Some packages (e.g. @swaggerexpert/json-pointer, +// @swagger-api/apidom-ns-openapi-3-0) ship runtime code inside dirs named "test" or "license", +// so we can't blindly remove these deep in the tree. +const ROOT_ONLY_PRUNE = new Set([ + 'test', 'tests', '__tests__', '__mocks__', 'spec', + 'benchmark', 'benchmarks', 'coverage', + 'LICENSE', 'license', 'LICENCE', +]) let prunedCount = 0 let prunedSize = 0 -function pruneDir(dirPath: string) { +function pruneDir(dirPath: string, isPackageRoot: boolean) { try { const entries = readdirSync(dirPath, { withFileTypes: true }) for (const entry of entries) { const fullPath = join(dirPath, entry.name) - // Prune by name - if (PRUNE_PATTERNS.includes(entry.name)) { + // Always-safe prune (docs, config, metadata) + if (SAFE_PRUNE.has(entry.name)) { try { const stat = statSync(fullPath) const size = entry.isDirectory() ? 0 : stat.size @@ -304,6 +310,16 @@ function pruneDir(dirPath: string) { } continue } + // Root-only prune: only remove test/coverage dirs at package root level + if (isPackageRoot && entry.isDirectory() && ROOT_ONLY_PRUNE.has(entry.name)) { + try { + rmSync(fullPath, { recursive: true }) + prunedCount++ + } catch (e) { + console.warn(` WARN: Failed to prune ${fullPath}: ${e}`) + } + continue + } // Prune by extension (including .d.ts — Bun doesn't need type declarations at runtime) if (entry.isFile()) { if ( @@ -327,9 +343,10 @@ function pruneDir(dirPath: string) { continue } } - // Recurse into directories + // Recurse into directories — mark as package root if it has a package.json if (entry.isDirectory()) { - pruneDir(fullPath) + const childIsRoot = existsSync(join(fullPath, 'package.json')) + pruneDir(fullPath, childIsRoot) } } } catch (e) { @@ -337,7 +354,7 @@ function pruneDir(dirPath: string) { } } -pruneDir(nmDest) +pruneDir(nmDest, false) console.log(`[collect-externals] Pruned ${prunedCount} files/dirs (${(prunedSize / 1024 / 1024).toFixed(1)}MB removed)`) // Remove prebuilds for OTHER platforms, build artifacts, and native source files diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 9fe60e6..d2265b9 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -8,9 +8,9 @@ import { Database } from 'bun:sqlite' import { Utils } from 'electrobun/bun' import { join } from 'node:path' import { mkdirSync } from 'node:fs' -import type { ChainBalance, CustomToken, CustomChain, PairedAppInfo, ApiLogEntry, ReportMeta, ReportData } from '../shared/types' +import type { ChainBalance, CustomToken, CustomChain, PairedAppInfo, ApiLogEntry, ReportMeta, ReportData, SwapHistoryRecord, SwapHistoryFilter, SwapTrackingStatus, SwapHistoryStats } from '../shared/types' -const SCHEMA_VERSION = '7' +const SCHEMA_VERSION = '8' let db: Database | null = null @@ -160,6 +160,42 @@ export function initDb() { `) db.exec(`CREATE INDEX IF NOT EXISTS idx_reports_created ON reports(created_at DESC)`) + db.exec(` + CREATE TABLE IF NOT EXISTS swap_history ( + id TEXT PRIMARY KEY, + txid TEXT NOT NULL, + from_asset TEXT NOT NULL, + to_asset TEXT NOT NULL, + from_symbol TEXT NOT NULL, + to_symbol TEXT NOT NULL, + from_chain_id TEXT NOT NULL, + to_chain_id TEXT NOT NULL, + from_amount TEXT NOT NULL, + quoted_output TEXT NOT NULL, + minimum_output TEXT NOT NULL DEFAULT '0', + received_output TEXT, + slippage_bps INTEGER NOT NULL DEFAULT 300, + fee_bps INTEGER NOT NULL DEFAULT 0, + fee_outbound TEXT NOT NULL DEFAULT '0', + integration TEXT NOT NULL DEFAULT 'thorchain', + memo TEXT NOT NULL DEFAULT '', + inbound_address TEXT NOT NULL DEFAULT '', + router TEXT, + status TEXT NOT NULL DEFAULT 'pending', + outbound_txid TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + estimated_time_secs INTEGER NOT NULL DEFAULT 0, + actual_time_secs INTEGER, + approval_txid TEXT + ) + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_created ON swap_history(created_at DESC)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_status ON swap_history(status)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_txid ON swap_history(txid)`) + // Migrations: add columns to existing tables (safe to re-run) for (const col of ['explorer_address_link TEXT', 'explorer_tx_link TEXT']) { try { db.exec(`ALTER TABLE custom_chains ADD COLUMN ${col}`) } catch { /* already exists */ } @@ -681,3 +717,188 @@ export function reportExists(id: string): boolean { } } +// ── Swap History ────────────────────────────────────────────────────── + +/** Insert a new swap history record (called when swap is first tracked) */ +export function insertSwapHistory(record: SwapHistoryRecord) { + try { + if (!db) return + db.run( + `INSERT OR REPLACE INTO swap_history + (id, txid, from_asset, to_asset, from_symbol, to_symbol, from_chain_id, to_chain_id, + from_amount, quoted_output, minimum_output, received_output, slippage_bps, fee_bps, + fee_outbound, integration, memo, inbound_address, router, status, outbound_txid, + error, created_at, updated_at, completed_at, estimated_time_secs, actual_time_secs, approval_txid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + record.id, record.txid, record.fromAsset, record.toAsset, + record.fromSymbol, record.toSymbol, record.fromChainId, record.toChainId, + record.fromAmount, record.quotedOutput, record.minimumOutput, + record.receivedOutput || null, + record.slippageBps, record.feeBps, record.feeOutbound, + record.integration, record.memo, record.inboundAddress, + record.router || null, record.status, record.outboundTxid || null, + record.error || null, record.createdAt, record.updatedAt, + record.completedAt || null, record.estimatedTimeSeconds, + record.actualTimeSeconds || null, record.approvalTxid || null, + ] + ) + } catch (e: any) { + console.warn('[db] insertSwapHistory failed:', e.message) + } +} + +/** Update swap status and related fields (called on every status change) */ +export function updateSwapHistoryStatus( + txid: string, + status: SwapTrackingStatus, + extra?: { + outboundTxid?: string + error?: string + receivedOutput?: string + completedAt?: number + actualTimeSeconds?: number + } +) { + try { + if (!db) return + const now = Date.now() + const isFinal = status === 'completed' || status === 'failed' || status === 'refunded' + + // Build SET clauses and params together to prevent misalignment + const setClauses: Array<{ col: string; value: any }> = [ + { col: 'status', value: status }, + { col: 'updated_at', value: now }, + ] + + if (extra?.outboundTxid) setClauses.push({ col: 'outbound_txid', value: extra.outboundTxid }) + if (extra?.error) setClauses.push({ col: 'error', value: extra.error }) + if (extra?.receivedOutput) setClauses.push({ col: 'received_output', value: extra.receivedOutput }) + if (isFinal) { + setClauses.push({ col: 'completed_at', value: extra?.completedAt || now }) + if (extra?.actualTimeSeconds !== undefined) { + setClauses.push({ col: 'actual_time_secs', value: extra.actualTimeSeconds }) + } + } + + const sql = `UPDATE swap_history SET ${setClauses.map(c => `${c.col} = ?`).join(', ')} WHERE txid = ?` + const params = [...setClauses.map(c => c.value), txid] + + db.run(sql, params) + } catch (e: any) { + console.warn('[db] updateSwapHistoryStatus failed:', e.message) + } +} + +/** Query swap history with optional filters */ +export function getSwapHistory(filter?: SwapHistoryFilter): SwapHistoryRecord[] { + try { + if (!db) return [] + + let sql = `SELECT * FROM swap_history WHERE 1=1` + const params: any[] = [] + + if (filter?.status && filter.status !== 'all') { + sql += ` AND status = ?` + params.push(filter.status) + } + if (filter?.fromDate) { + sql += ` AND created_at >= ?` + params.push(filter.fromDate) + } + if (filter?.toDate) { + sql += ` AND created_at <= ?` + params.push(filter.toDate) + } + if (filter?.asset) { + sql += ` AND (from_symbol LIKE ? OR to_symbol LIKE ? OR from_asset LIKE ? OR to_asset LIKE ?)` + const q = `%${filter.asset}%` + params.push(q, q, q, q) + } + + sql += ` ORDER BY created_at DESC` + + const limit = filter?.limit || 100 + const offset = filter?.offset || 0 + sql += ` LIMIT ? OFFSET ?` + params.push(limit, offset) + + const rows = db.query(sql).all(...params) as any[] + return rows.map(mapSwapRow) + } catch (e: any) { + console.warn('[db] getSwapHistory failed:', e.message) + return [] + } +} + +/** Get a single swap history record by txid */ +export function getSwapHistoryByTxid(txid: string): SwapHistoryRecord | null { + try { + if (!db) return null + const row = db.query('SELECT * FROM swap_history WHERE txid = ?').get(txid) as any + return row ? mapSwapRow(row) : null + } catch (e: any) { + console.warn('[db] getSwapHistoryByTxid failed:', e.message) + return null + } +} + +/** Get aggregate stats for swap history */ +export function getSwapHistoryStats(): SwapHistoryStats { + try { + if (!db) return { totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 } + const row = db.query(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, + SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded, + SUM(CASE WHEN status NOT IN ('completed', 'failed', 'refunded') THEN 1 ELSE 0 END) as pending + FROM swap_history + `).get() as any + return { + totalSwaps: row?.total || 0, + completed: row?.completed || 0, + failed: row?.failed || 0, + refunded: row?.refunded || 0, + pending: row?.pending || 0, + } + } catch (e: any) { + console.warn('[db] getSwapHistoryStats failed:', e.message) + return { totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 } + } +} + +function mapSwapRow(r: any): SwapHistoryRecord { + return { + id: r.id, + txid: r.txid, + fromAsset: r.from_asset, + toAsset: r.to_asset, + fromSymbol: r.from_symbol, + toSymbol: r.to_symbol, + fromChainId: r.from_chain_id, + toChainId: r.to_chain_id, + fromAmount: r.from_amount, + quotedOutput: r.quoted_output, + minimumOutput: r.minimum_output, + receivedOutput: r.received_output || undefined, + slippageBps: r.slippage_bps, + feeBps: r.fee_bps, + feeOutbound: r.fee_outbound, + integration: r.integration, + memo: r.memo, + inboundAddress: r.inbound_address, + router: r.router || undefined, + status: r.status as SwapTrackingStatus, + outboundTxid: r.outbound_txid || undefined, + error: r.error || undefined, + createdAt: r.created_at, + updatedAt: r.updated_at, + completedAt: r.completed_at || undefined, + estimatedTimeSeconds: r.estimated_time_secs, + actualTimeSeconds: r.actual_time_secs || undefined, + approvalTxid: r.approval_txid || undefined, + } +} + diff --git a/projects/keepkey-vault/src/bun/evm-rpc.ts b/projects/keepkey-vault/src/bun/evm-rpc.ts index 200308b..540baeb 100644 --- a/projects/keepkey-vault/src/bun/evm-rpc.ts +++ b/projects/keepkey-vault/src/bun/evm-rpc.ts @@ -70,6 +70,23 @@ export async function getTokenMetadata(rpcUrl: string, contractAddress: string): return { symbol, name, decimals: isNaN(decimals) ? 18 : decimals } } +/** Check ERC-20 allowance(owner, spender) via eth_call */ +export async function getErc20Allowance(rpcUrl: string, tokenContract: string, owner: string, spender: string): Promise { + const selector = 'dd62ed3e' // allowance(address,address) + const ownerPad = owner.toLowerCase().replace(/^0x/, '').padStart(64, '0') + const spenderPad = spender.toLowerCase().replace(/^0x/, '').padStart(64, '0') + const data = '0x' + selector + ownerPad + spenderPad + const result = await ethCall(rpcUrl, tokenContract, data) + return BigInt(result || '0x0') +} + +/** Get ERC-20 decimals via eth_call */ +export async function getErc20Decimals(rpcUrl: string, tokenContract: string): Promise { + const result = await ethCall(rpcUrl, tokenContract, '0x313ce567') // decimals() + // If RPC returns empty/zero, fall back to 18 (0x12 hex = 18 decimal, the ERC-20 standard default) + return Number(BigInt(result || '0x12')) +} + // ── Direct RPC methods for custom chains ───────────────────────────── export async function getEvmBalance(rpcUrl: string, address: string): Promise { @@ -87,12 +104,50 @@ export async function getEvmNonce(rpcUrl: string, address: string): Promise { + try { + const result = await ethRpc(rpcUrl, 'eth_estimateGas', [tx]) + const estimated = BigInt(result || '0x0') + return estimated > 0n ? estimated * 120n / 100n : fallbackGas // 20% buffer + } catch { + return fallbackGas + } +} + export async function broadcastEvmTx(rpcUrl: string, signedTxHex: string): Promise { const hex = signedTxHex.startsWith('0x') ? signedTxHex : `0x${signedTxHex}` const result = await ethRpc(rpcUrl, 'eth_sendRawTransaction', [hex]) return result } +/** Poll for tx receipt, returning null if not mined within maxWaitMs */ +export async function waitForTxReceipt( + rpcUrl: string, + txHash: string, + maxWaitMs = 60_000, + pollMs = 3_000, +): Promise<{ status: boolean; gasUsed: bigint } | null> { + const start = Date.now() + while (Date.now() - start < maxWaitMs) { + try { + const receipt = await ethRpc(rpcUrl, 'eth_getTransactionReceipt', [txHash]) + if (receipt && receipt.status !== undefined) { + return { + status: receipt.status === '0x1', + gasUsed: BigInt(receipt.gasUsed || '0x0'), + } + } + } catch { /* not mined yet */ } + await new Promise(r => setTimeout(r, pollMs)) + } + return null // timed out +} + export async function getEvmChainId(rpcUrl: string): Promise { const result = await ethRpc(rpcUrl, 'eth_chainId', []) return Number(BigInt(result || '0x0')) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index cf7ab88..edaa1a1 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -19,7 +19,7 @@ import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chain import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" import { EvmAddressManager, evmAddressPath } from "./evm-addresses" -import { initDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists } from "./db" +import { initDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats } from "./db" import { generateReport, reportToPdfBuffer } from "./reports" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" @@ -33,9 +33,6 @@ import type { VaultRPCSchema } from "../shared/rpc-schema" const PIONEER_TIMEOUT_MS = 60_000 // ── Pioneer chain discovery catalog (lazy-loaded, 30-min cache) ────── -function getDiscoveryUrl(): string { - return `${getPioneerApiBase()}/api/v1/discovery/search` -} const CATALOG_TTL = 30 * 60 * 1000 // 30 minutes let chainCatalog: PioneerChainInfo[] = [] let catalogLoadedAt = 0 @@ -76,22 +73,22 @@ async function loadChainCatalog(): Promise { if (catalogLoading) return catalogLoading catalogLoading = (async () => { try { + const pioneer = await getPioneer() const results: PioneerChainInfo[] = [] - // Fetch all queries in parallel for speed - const baseUrl = getDiscoveryUrl() + // Fetch all queries in parallel via Pioneer client const fetches = CATALOG_QUERIES.map(async (q) => { try { - const resp = await fetch(`${baseUrl}?q=${q}&limit=2000`, { signal: AbortSignal.timeout(15_000) }) - if (!resp.ok) return [] - return (await resp.json()) as any[] + const resp = await pioneer.SearchAssets({ q, limit: 2000 }) + return resp?.data || resp || [] } catch { return [] } }) const batches = await Promise.all(fetches) const byChainId = new Map() for (const raw of batches) { - for (const entry of raw) { + const entries = Array.isArray(raw) ? raw : [] + for (const entry of entries) { const parsed = parseRawEntry(entry) if (!parsed) continue const existing = byChainId.get(parsed.chainId) @@ -180,11 +177,18 @@ function getRpcUrl(chain: ChainDef): string | undefined { // ── REST API Server (opt-in, persisted in DB, default OFF) ───────────── const auth = new AuthStore() let restApiEnabled = getSetting('rest_api_enabled') === '1' // default OFF +let swapsEnabled = getSetting('swaps_enabled') === '1' // default OFF let appVersionCache = '' let restServer: ReturnType | null = null function getAppSettings() { - return { restApiEnabled, pioneerApiBase: getPioneerApiBase() } + return { + restApiEnabled, + pioneerApiBase: getPioneerApiBase(), + fiatCurrency: getSetting('fiat_currency') || 'USD', + numberLocale: getSetting('number_locale') || 'en-US', + swapsEnabled, + } } // Callbacks bridge REST → RPC UI @@ -239,6 +243,10 @@ function applyRestApiState() { applyRestApiState() if (!restApiEnabled) console.log('[Vault] REST API disabled (enable in Settings → Application)') +// ── Swap quote cache (last 10 quotes for tracker data) ─────────────── +import type { SwapQuote } from '../shared/types' +const swapQuoteCache = new Map() + // ── RPC Bridge (Electrobun UI ↔ Bun) ───────────────────────────────── const rpc = BrowserView.defineRPC({ maxRequestTime: 1_800_000, // 30 minutes — generous for device-interactive ops, but not infinite @@ -1251,6 +1259,22 @@ const rpc = BrowserView.defineRPC({ console.log('[settings] Pioneer API base set to:', url || '(default)') return getAppSettings() }, + setFiatCurrency: async (params) => { + setSetting('fiat_currency', params.currency || 'USD') + console.log('[settings] Fiat currency set to:', params.currency) + return getAppSettings() + }, + setNumberLocale: async (params) => { + setSetting('number_locale', params.locale || 'en-US') + console.log('[settings] Number locale set to:', params.locale) + return getAppSettings() + }, + setSwapsEnabled: async (params) => { + swapsEnabled = params.enabled + setSetting('swaps_enabled', params.enabled ? '1' : '0') + console.log('[settings] Swaps enabled:', params.enabled) + return getAppSettings() + }, // ── API Audit Log ──────────────────────────────────────── getApiLogs: async (params) => { @@ -1420,6 +1444,169 @@ const rpc = BrowserView.defineRPC({ return { filePath } }, + // ── Swap (quote cache for tracker) ────────────────────── + getSwapAssets: async () => { + const { getSwapAssets } = await import('./swap') + return await getSwapAssets() + }, + getSwapQuote: async (params) => { + const { getSwapQuote, THOR_TO_CHAIN, parseThorAsset } = await import('./swap') + + // Resolve xpub addresses to real receive addresses for UTXO chains. + // ChainBalance.address can be an xpub when Pioneer doesn't return + // an address field — THORChain rejects xpubs as destination addresses. + // Detect extended pubkeys: xpub/ypub/zpub (BTC), dgub (DOGE), Ltub/Mtub (LTC), drkp (DASH), tpub (testnet) + const isXpub = (addr: string) => /^(xpub|ypub|zpub|dgub|Ltub|Mtub|drkp|drks|tpub|upub|vpub)/.test(addr) + + if (engine.wallet) { + const resolveAddr = async (thorAsset: string, addr: string): Promise => { + if (!isXpub(addr)) return addr + const parsed = parseThorAsset(thorAsset) + const chainId = THOR_TO_CHAIN[parsed.chain] + if (!chainId) return addr + const chainDef = getAllChains().find(c => c.id === chainId) + if (!chainDef || chainDef.chainFamily !== 'utxo') return addr + try { + const result = await engine.wallet.btcGetAddress({ + addressNList: chainDef.defaultPath, + coin: chainDef.coin, + scriptType: chainDef.scriptType, + showDisplay: false, + }) + const resolved = typeof result === 'string' ? result : result?.address + if (resolved) { + console.log(`[swap] Resolved xpub → ${resolved} for ${thorAsset}`) + return resolved + } + } catch (e: any) { + console.warn(`[swap] Failed to resolve xpub for ${thorAsset}: ${e.message}`) + } + return addr + } + params = { + ...params, + fromAddress: await resolveAddr(params.fromAsset, params.fromAddress), + toAddress: await resolveAddr(params.toAsset, params.toAddress), + } + } + + // Fail fast if addresses are still xpubs after resolution attempt + if (isXpub(params.fromAddress)) { + throw new Error(`Could not resolve source address for ${params.fromAsset} — device may be locked or disconnected`) + } + if (isXpub(params.toAddress)) { + throw new Error(`Could not resolve destination address for ${params.toAsset} — device may be locked or disconnected`) + } + + const quote = await getSwapQuote(params) + // Cache quote so executeSwap can pass real data to the tracker + const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}-${params.slippageBps || 300}-${params.fromAddress}-${params.toAddress}` + swapQuoteCache.delete(cacheKey) // delete+set for LRU ordering + swapQuoteCache.set(cacheKey, quote) + // Keep cache small (last 10 quotes) + if (swapQuoteCache.size > 10) { + const oldest = swapQuoteCache.keys().next().value + if (oldest) swapQuoteCache.delete(oldest) + } + return quote + }, + executeSwap: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const { executeSwap } = await import('./swap') + const { trackSwap } = await import('./swap-tracker') + const result = await executeSwap(params, { + wallet: engine.wallet, + getAllChains, + getRpcUrl, + getBtcXpub: () => { + if (btcAccounts.isInitialized) { + const selected = btcAccounts.getSelectedXpub() + if (selected) return selected.xpub + } + return undefined + }, + }) + // Look up cached quote for real tracker data (match by asset+amount, addresses from getSwapQuote context) + // Try exact match first, then fallback to base key without addresses + const baseKey = `${params.fromAsset}-${params.toAsset}-${params.amount}` + let cachedQuote: Awaited> | undefined + for (const [key, val] of swapQuoteCache) { + if (key.startsWith(baseKey) && val.inboundAddress === params.inboundAddress) { + cachedQuote = val + break + } + } + if (!cachedQuote) { + // Fallback: find any quote matching the base key + for (const [key, val] of swapQuoteCache) { + if (key.startsWith(baseKey)) { cachedQuote = val; break } + } + } + if (!cachedQuote) console.warn('[index] No cached quote for swap tracker — using fallback data') + // Register swap for tracking (non-blocking) + try { + trackSwap(result, params, { + expectedOutput: cachedQuote?.expectedOutput || params.expectedOutput, + minimumOutput: cachedQuote?.minimumOutput || '0', + inboundAddress: cachedQuote?.inboundAddress || params.inboundAddress, + router: cachedQuote?.router || params.router, + memo: cachedQuote?.memo || params.memo, + expiry: cachedQuote?.expiry || params.expiry, + fees: cachedQuote?.fees || { affiliate: '0', outbound: '0', totalBps: 0 }, + estimatedTime: cachedQuote?.estimatedTime || 600, + slippageBps: cachedQuote?.slippageBps || 300, + fromAsset: params.fromAsset, + toAsset: params.toAsset, + integration: cachedQuote?.integration || 'thorchain', + }) + } catch (e: any) { + console.warn('[index] Failed to register swap for tracking:', e.message) + } + return result + }, + getPendingSwaps: async () => { + const { getPendingSwaps } = await import('./swap-tracker') + return getPendingSwaps() + }, + dismissSwap: async (params) => { + const { dismissSwap } = await import('./swap-tracker') + dismissSwap(params.txid) + }, + + // ── Swap History (SQLite-persisted) ───────────────────── + getSwapHistory: async (params) => { + return getSwapHistory(params || undefined) + }, + getSwapHistoryStats: async () => { + return getSwapHistoryStats() + }, + exportSwapReport: async (params) => { + const records = getSwapHistory({ + fromDate: params.fromDate, + toDate: params.toDate, + limit: 10000, + }) + if (records.length === 0) throw new Error('No swap records to export') + + const dir = path.join(os.homedir(), 'Downloads') + + if (params.format === 'csv') { + const { generateSwapCsv } = await import('./swap-report') + const csv = generateSwapCsv(records) + const fileName = `keepkey-swaps-${new Date().toISOString().slice(0, 10)}.csv` + const filePath = path.join(dir, fileName) + await Bun.write(filePath, csv) + return { filePath } + } else { + const { generateSwapPdf } = await import('./swap-report') + const pdfBuffer = await generateSwapPdf(records) + const fileName = `keepkey-swaps-${new Date().toISOString().slice(0, 10)}.pdf` + const filePath = path.join(dir, fileName) + await Bun.write(filePath, pdfBuffer) + return { filePath } + } + }, + // ── Balance cache (instant portfolio) ──────────────────── getCachedBalances: async () => { const deviceId = engine.getDeviceState().deviceId @@ -1497,6 +1684,22 @@ const rpc = BrowserView.defineRPC({ }, }) +// Initialize swap tracker with typed RPC message sender +// FAIL FAST: If Pioneer SDK doesn't have swap tracking methods, crash the app +import('./swap-tracker').then(async ({ initSwapTracker }) => { + await initSwapTracker((msg: string, data: any) => { + try { + if (msg === 'swap-update') rpc.send['swap-update'](data) + else if (msg === 'swap-complete') rpc.send['swap-complete'](data) + else console.error(`[swap-tracker] Unknown message: ${msg}`) + } catch (e: any) { + console.warn(`[swap-tracker] Failed to send '${msg}':`, e.message) + } + }) +}).catch((e) => { + console.error('[swap-tracker] Failed to initialize swap tracker (swaps will be unavailable):', e.message || e) +}) + // Push engine events to WebView engine.on('state-change', (state) => { try { rpc.send['device-state'](state) } catch { /* webview not ready yet */ } diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index 11d9686..7f8012d 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -13,9 +13,7 @@ import type { ReportData, ReportSection, ChainBalance } from '../shared/types' import { getLatestDeviceSnapshot, getCachedPubkeys } from './db' -import { getPioneerApiBase } from './pioneer' - -const REPORT_TIMEOUT_MS = 60_000 +import { getPioneer } from './pioneer' /** Section title prefixes — shared with tax-export.ts for reliable extraction. */ export const SECTION_TITLES = { @@ -35,35 +33,12 @@ function safeRoundSats(value: unknown): number { return Math.round(n) } -function getPioneerQueryKey(): string { - return process.env.PIONEER_API_KEY || `key:public-${Date.now()}` -} - -function getPioneerBase(): string { - return getPioneerApiBase() -} - -// ── Pioneer API Helpers ────────────────────────────────────────────── +// ── Pioneer API Helpers (via SDK client) ───────────────────────────── -async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - try { - return await fetch(url, { ...init, signal: controller.signal }) - } finally { - clearTimeout(timer) - } -} - -async function fetchPubkeyInfo(baseUrl: string, xpub: string): Promise { - const resp = await fetchWithTimeout( - `${baseUrl}/api/v1/utxo/pubkey-info/BTC/${xpub}`, - { method: 'GET', headers: { 'Authorization': getPioneerQueryKey() } }, - REPORT_TIMEOUT_MS, - ) - if (!resp.ok) throw new Error(`PubkeyInfo ${resp.status}`) - const json = await resp.json() - const result = json.data || json +async function fetchPubkeyInfo(xpub: string): Promise { + const pioneer = await getPioneer() + const resp = await pioneer.GetPubkeyInfo({ network: 'BTC', xpub }) + const result = resp?.data || resp if (typeof result !== 'object' || result === null) { console.warn('[Report] fetchPubkeyInfo: unexpected response shape, returning empty object') return {} @@ -71,23 +46,15 @@ async function fetchPubkeyInfo(baseUrl: string, xpub: string): Promise { return result } -async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Promise { - const resp = await fetchWithTimeout( - `${baseUrl}/api/v1/tx/history`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': getPioneerQueryKey() }, - body: JSON.stringify({ queries: [{ pubkey: xpub, caip }] }), - }, - REPORT_TIMEOUT_MS, - ) - if (!resp.ok) throw new Error(`TxHistory ${resp.status}`) - const json = await resp.json() - if (typeof json !== 'object' || json === null) { +async function fetchTxHistory(xpub: string, caip: string): Promise { + const pioneer = await getPioneer() + const resp = await pioneer.GetTxHistory({ queries: [{ pubkey: xpub, caip }] }) + const data = resp?.data || resp + if (typeof data !== 'object' || data === null) { console.warn('[Report] fetchTxHistory: unexpected response shape, returning empty array') return [] } - const histories = json.histories || json.data?.histories || [] + const histories = data.histories || data?.data?.histories || [] return histories[0]?.transactions || [] } @@ -265,7 +232,6 @@ interface BtcTx { } async function buildBtcSections( - baseUrl: string, btcXpubs: Array<{ xpub: string; scriptType: string; path: number[] }>, onProgress?: (msg: string, pct: number) => void, ): Promise { @@ -277,7 +243,7 @@ async function buildBtcSections( for (const x of btcXpubs) { if (!x.xpub) continue try { - const info = await fetchPubkeyInfo(baseUrl, x.xpub) + const info = await fetchPubkeyInfo(x.xpub) const tokens = info.tokens || [] const used = tokens.filter((t: any) => (t.transfers || 0) > 0) xpubInfos.push({ @@ -360,7 +326,7 @@ async function buildBtcSections( for (const x of btcXpubs) { if (!x.xpub) continue try { - const txs = await fetchTxHistory(baseUrl, x.xpub, BTC_CAIP) + const txs = await fetchTxHistory(x.xpub, BTC_CAIP) for (const tx of txs) { if (!seenTxids.has(tx.txid)) { seenTxids.add(tx.txid) @@ -571,7 +537,7 @@ export async function generateReport(opts: GenerateReportOptions): Promise ({ ...b })) - const baseUrl = getPioneerBase() + // Pioneer client handles routing internally — no base URL needed const sections: ReportSection[] = [] const now = new Date() @@ -591,28 +557,19 @@ export async function generateReport(opts: GenerateReportOptions): Promise 0) { const btcBalance = totalSats / 1e8 - // Fetch BTC price from Pioneer market endpoint + // Fetch BTC price via Pioneer client let btcUsd = 0 try { - const priceResp = await fetchWithTimeout( - `${baseUrl}/api/v1/market/info`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': getPioneerQueryKey() }, - body: JSON.stringify([BTC_CAIP]), - }, - 10_000, - ) - if (priceResp.ok) { - const priceData = await priceResp.json() - const price = priceData?.data?.[0] || priceData?.[0] || 0 - btcUsd = btcBalance * (typeof price === 'number' ? price : parseFloat(String(price)) || 0) - } + const pioneer = await getPioneer() + const priceResp = await pioneer.GetMarketInfo([BTC_CAIP]) + const priceData = priceResp?.data || priceResp + const price = Array.isArray(priceData) ? priceData[0] : priceData?.data?.[0] || 0 + btcUsd = btcBalance * (typeof price === 'number' ? price : parseFloat(String(price)) || 0) } catch (e: any) { console.warn('[reports] BTC price fetch failed:', e.message) } @@ -662,7 +619,7 @@ export async function generateReport(opts: GenerateReportOptions): Promise 0) { try { - const btcSections = await buildBtcSections(baseUrl, btcXpubs, onProgress) + const btcSections = await buildBtcSections(btcXpubs, onProgress) sections.push(...btcSections) onProgress?.('BTC report complete', 80) } catch (e: any) { diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts new file mode 100644 index 0000000..9dc4329 --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -0,0 +1,211 @@ +/** + * Pure parsing functions for Pioneer swap API responses. + * + * Extracted from swap.ts to allow unit testing without side effects + * (no Pioneer client, no DB, no server imports). + */ +import { CHAINS } from '../shared/chains' +import type { SwapAsset, SwapQuote } from '../shared/types' + +const TAG = '[swap]' + +// ── Asset mapping helpers ─────────────────────────────────────────── + +/** Parse a THORChain asset string (e.g. "ETH.USDT-0xDAC...") into parts */ +export function parseThorAsset(asset: string): { chain: string; symbol: string; contractAddress?: string } { + const [chain, rest] = asset.split('.') + if (!rest) return { chain, symbol: chain } + const dashIdx = rest.indexOf('-') + if (dashIdx === -1) return { chain, symbol: rest } + return { chain, symbol: rest.slice(0, dashIdx), contractAddress: rest.slice(dashIdx + 1) } +} + +/** Map THORChain chain prefixes to our chain IDs */ +export const THOR_TO_CHAIN: Record = { + BTC: 'bitcoin', + ETH: 'ethereum', + LTC: 'litecoin', + DOGE: 'dogecoin', + BCH: 'bitcoincash', + DASH: 'dash', + GAIA: 'cosmos', + THOR: 'thorchain', + MAYA: 'mayachain', + AVAX: 'avalanche', + BSC: 'bsc', + BASE: 'base', + ARB: 'arbitrum', + OP: 'optimism', + MATIC: 'polygon', +} + +// ── Quote parsing ─────────────────────────────────────────────────── + +/** + * Parse a raw Pioneer Quote SDK response into our SwapQuote type. + * Pure function — no network calls, no side effects. + */ +export function parseQuoteResponse( + quoteResp: any, + params: { fromAsset: string; toAsset: string; slippageBps?: number }, +): SwapQuote { + // Pioneer SDK wraps responses: { data: { success, data: [...] } } + const qOuter = quoteResp?.data || quoteResp + const qInner = qOuter?.data || qOuter + if (!qInner) throw new Error('Pioneer Quote returned empty response') + + // Pioneer returns array of quotes from different integrations — pick best + const quotes: any[] = Array.isArray(qInner) ? qInner : [qInner] + if (quotes.length === 0) throw new Error('No quotes available for this pair') + + // Select first (best) quote + const best = quotes[0] + const quote = best.quote || best + // Pioneer wraps THORNode data in quote.raw and tx details in quote.txs[] + const raw = quote.raw || {} + const txParams = quote.txs?.[0]?.txParams || {} + + // Extract fields from Pioneer's normalized fields + raw THORNode data + const expectedOutput = quote.buyAmount || quote.amountOut || raw.expected_amount_out + if (!expectedOutput) throw new Error('Quote response missing output amount') + const expectedOutputStr = String(expectedOutput) + + // Memo lives in txParams (Pioneer constructs it), fallback to raw + const memo = txParams.memo || quote.memo || raw.memo || '' + // 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 + let inboundAddress = quote.inbound_address || quote.inboundAddress + || raw.inbound_address || raw.inboundAddress + || txParams.vaultAddress || txParams.vault_address + || txParams.to + || best.inbound_address || best.inboundAddress + + // Last-resort fallback: for UTXO swaps, THORChain's "router" IS the vault address + // (EVM router is a contract, but UTXO "router" is the inbound vault) + if (!inboundAddress && router) { + console.warn(`${TAG} No explicit inbound_address — falling back to router: ${router}`) + inboundAddress = router + } + + // Expiry for depositWithExpiry + const expiry = raw.expiry || quote.expiry || 0 + + if (!inboundAddress) { + // Dump full response structure to help diagnose missing field + console.error(`${TAG} MISSING inbound address — dumping response structure:`) + console.error(`${TAG} best keys: ${Object.keys(best).join(', ')}`) + console.error(`${TAG} quote keys: ${Object.keys(quote).join(', ')}`) + console.error(`${TAG} raw keys: ${Object.keys(raw).join(', ')}`) + console.error(`${TAG} txParams keys: ${Object.keys(txParams).join(', ')}`) + console.error(`${TAG} full best: ${JSON.stringify(best, null, 2).slice(0, 2000)}`) + throw new Error('Quote response missing inbound address') + } + if (!memo) console.warn(`${TAG} WARNING: Quote has no memo — tx may fail`) + + // Extract fees from raw THORNode response + const fees = raw.fees || quote.fees || {} + const totalBps = fees.total_bps || fees.totalBps || 0 + const outboundFee = fees.outbound || fees.outboundFee || '0' + const affiliateFee = fees.affiliate || fees.affiliateFee || '0' + const actualSlippageBps = fees.slippage_bps || fees.slippageBps || (params.slippageBps ?? 300) + + // Minimum output — Pioneer provides amountOutMin, fallback to slippage calc + const expectedNum = parseFloat(expectedOutputStr) + const minOut = quote.amountOutMin + ? parseFloat(quote.amountOutMin) + : expectedNum * (1 - actualSlippageBps / 10000) + + // Estimated time — prefer total_swap_seconds (full swap duration) over + // inbound_confirmation_seconds (just the inbound leg, much shorter) + const estimatedTime = raw.total_swap_seconds || quote.totalSwapSeconds + || quote.estimatedTime || raw.inbound_confirmation_seconds || 600 + + const minOutStr = minOut > 0 ? minOut.toFixed(8).replace(/\.?0+$/, '') : '0' + + return { + expectedOutput: expectedOutputStr, + minimumOutput: minOutStr, + inboundAddress, + router, + memo, + expiry: Number(expiry), + fees: { + affiliate: String(affiliateFee), + outbound: String(outboundFee), + totalBps: Number(totalBps), + }, + estimatedTime: Number(estimatedTime), + warning: raw.warning || quote.warning || undefined, + slippageBps: Number(actualSlippageBps), + fromAsset: params.fromAsset, + toAsset: params.toAsset, + integration: best.integration || 'thorchain', + } +} + +// ── Assets parsing ────────────────────────────────────────────────── + +/** + * Parse a raw Pioneer GetAvailableAssets response into SwapAsset[]. + * Pure function — no network calls, no side effects. + */ +export function parseAssetsResponse(resp: any): SwapAsset[] { + const outer = resp?.data || resp + const inner = outer?.data || outer + if (!inner) throw new Error('Pioneer GetAvailableAssets returned empty response') + + const rawAssets: any[] = inner.assets || inner + if (!Array.isArray(rawAssets)) { + throw new Error('Pioneer GetAvailableAssets: unexpected response shape') + } + + const assets: SwapAsset[] = [] + + for (const raw of rawAssets) { + const thorAsset = raw.asset || raw.thorAsset || raw.name + if (!thorAsset) continue + + const parsed = parseThorAsset(thorAsset) + const ourChainId = THOR_TO_CHAIN[parsed.chain] + if (!ourChainId) continue + + const chainDef = CHAINS.find(c => c.id === ourChainId) + if (!chainDef) continue + + const isToken = !!parsed.contractAddress + + assets.push({ + asset: thorAsset, + chainId: ourChainId, + symbol: raw.symbol || parsed.symbol, + name: raw.name || (isToken ? `${parsed.symbol} (${chainDef.coin})` : chainDef.coin), + chainFamily: chainDef.chainFamily as 'utxo' | 'evm' | 'cosmos' | 'xrp', + decimals: raw.decimals ?? chainDef.decimals, + caip: raw.caip || chainDef.caip, + contractAddress: parsed.contractAddress, + icon: raw.icon || raw.image, + }) + } + + return assets +} + +/** Convert our chain CAIP + asset info into the CAIP format Pioneer Quote expects */ +export function assetToCaip(thorAsset: string): string { + const parsed = parseThorAsset(thorAsset) + const ourChainId = THOR_TO_CHAIN[parsed.chain] + if (!ourChainId) throw new Error(`Unsupported THORChain chain: ${parsed.chain}`) + + const chainDef = CHAINS.find(c => c.id === ourChainId) + if (!chainDef) throw new Error(`No chain def for: ${ourChainId}`) + + // For ERC-20 tokens, build eip155:N/erc20:0x... CAIP + if (parsed.contractAddress) { + const networkParts = chainDef.networkId // e.g. "eip155:1" + return `${networkParts}/erc20:${parsed.contractAddress}` + } + + // Native asset — use the chain's CAIP-19 + return chainDef.caip +} diff --git a/projects/keepkey-vault/src/bun/swap-report.ts b/projects/keepkey-vault/src/bun/swap-report.ts new file mode 100644 index 0000000..819e9b6 --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap-report.ts @@ -0,0 +1,308 @@ +/** + * Swap Report Generator — PDF and CSV export for swap history. + * + * Uses pdf-lib (same as reports.ts) for PDF generation. + * CSV is plain-text, compatible with spreadsheet apps and tax tools. + */ +import type { SwapHistoryRecord } from '../shared/types' + +// ── CSV Export ──────────────────────────────────────────────────────── + +const CSV_HEADERS = [ + 'Date', 'Status', 'From Asset', 'To Asset', 'Amount Sent', 'Quoted Output', + 'Minimum Output', 'Received Output', 'Slippage (bps)', 'Fee (bps)', + 'Outbound Fee', 'Integration', 'Inbound TXID', 'Outbound TXID', + 'Duration (s)', 'Error', +] + +function csvEscape(val: string): string { + if (val.includes(',') || val.includes('"') || val.includes('\n')) { + return `"${val.replace(/"/g, '""')}"` + } + return val +} + +export function generateSwapCsv(records: SwapHistoryRecord[]): string { + const lines = [CSV_HEADERS.join(',')] + + for (const r of records) { + const row = [ + new Date(r.createdAt).toISOString(), + r.status, + `${r.fromSymbol} (${r.fromAsset})`, + `${r.toSymbol} (${r.toAsset})`, + r.fromAmount, + r.quotedOutput, + r.minimumOutput, + r.receivedOutput || '', + String(r.slippageBps), + String(r.feeBps), + r.feeOutbound, + r.integration, + r.txid, + r.outboundTxid || '', + r.actualTimeSeconds !== undefined ? String(r.actualTimeSeconds) : '', + r.error || '', + ] + lines.push(row.map(csvEscape).join(',')) + } + + return lines.join('\n') +} + +// ── PDF Export ──────────────────────────────────────────────────────── + +// Status colors for the PDF +const STATUS_COLORS: Record = { + completed: { r: 0.18, g: 0.71, b: 0.35 }, + failed: { r: 0.85, g: 0.20, b: 0.20 }, + refunded: { r: 0.93, g: 0.55, b: 0.17 }, + pending: { r: 0.95, g: 0.73, b: 0.13 }, + confirming: { r: 0.22, g: 0.50, b: 0.92 }, +} + +function sanitize(text: string): string { + return text.replace(/[^\x20-\x7E]/g, '?') +} + +export async function generateSwapPdf(records: SwapHistoryRecord[]): Promise { + const { PDFDocument, StandardFonts, rgb } = await import('pdf-lib') + + const doc = await PDFDocument.create() + const font = await doc.embedFont(StandardFonts.Helvetica) + const bold = await doc.embedFont(StandardFonts.HelveticaBold) + + // Landscape Letter + const pageW = 792 + const pageH = 612 + const ML = 40 // margin left + const MR = 40 // margin right + const MT = 50 // margin top + const MB = 50 // margin bottom + const contentW = pageW - ML - MR + + let page = doc.addPage([pageW, pageH]) + let pageNum = 1 + let y = pageH - MT + + function newPage() { + page.drawText(`Page ${pageNum}`, { + x: pageW / 2 - 20, y: 25, font, size: 9, + color: rgb(0.5, 0.5, 0.5), + }) + page = doc.addPage([pageW, pageH]) + pageNum++ + y = pageH - MT + } + + function needSpace(needed: number) { + if (y - needed < MB) newPage() + } + + function drawText(text: string, x: number, yPos: number, f: any, s: number, c: { r: number; g: number; b: number }, maxW?: number) { + let t = sanitize(text) + if (maxW && f.widthOfTextAtSize(t, s) > maxW) { + let lo = 0, hi = t.length + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (f.widthOfTextAtSize(t.slice(0, mid), s) <= maxW) lo = mid + else hi = mid - 1 + } + if (lo < t.length && lo > 2) t = t.slice(0, lo - 2) + '..' + } + if (!t) return + try { + page.drawText(t, { x, y: yPos, font: f, size: s, color: rgb(c.r, c.g, c.b) }) + } catch { /* skip unprintable */ } + } + + const white = { r: 1, g: 1, b: 1 } + const gray = { r: 0.5, g: 0.5, b: 0.5 } + const dark = { r: 0.15, g: 0.15, b: 0.15 } + const brand = { r: 0.14, g: 0.86, b: 0.78 } + + // ── Title Page ────────────────────────────────────────────────── + drawText('KeepKey Vault', ML, y, bold, 22, dark) + y -= 28 + drawText('Swap History Report', ML, y, bold, 16, brand) + y -= 20 + drawText(`Generated: ${new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC`, ML, y, font, 10, gray) + y -= 14 + drawText(`Total Records: ${records.length}`, ML, y, font, 10, gray) + y -= 30 + + // ── Summary Stats ─────────────────────────────────────────────── + const completed = records.filter(r => r.status === 'completed').length + const failed = records.filter(r => r.status === 'failed').length + const refunded = records.filter(r => r.status === 'refunded').length + const pending = records.length - completed - failed - refunded + + needSpace(60) + drawText('Summary', ML, y, bold, 13, dark) + y -= 18 + + const stats = [ + `Completed: ${completed}`, + `Failed: ${failed}`, + `Refunded: ${refunded}`, + `Pending/In-Progress: ${pending}`, + ] + for (const s of stats) { + drawText(s, ML + 10, y, font, 10, dark) + y -= 14 + } + y -= 16 + + // ── Swap Table ────────────────────────────────────────────────── + // Columns: Date | Pair | Sent | Quoted | Received | Status | Duration | Integration + const cols = [ + { label: 'Date', w: 105 }, + { label: 'Pair', w: 90 }, + { label: 'Sent', w: 80 }, + { label: 'Quoted Out', w: 80 }, + { label: 'Received', w: 80 }, + { label: 'Status', w: 75 }, + { label: 'Duration', w: 65 }, + { label: 'Integration', w: 65 }, + ] + + // Header row + needSpace(30) + page.drawRectangle({ x: ML, y: y - 4, width: contentW, height: 18, color: rgb(0.12, 0.12, 0.15) }) + let colX = ML + 4 + for (const col of cols) { + drawText(col.label, colX, y, bold, 8, { r: 0.85, g: 0.85, b: 0.85 }) + colX += col.w + } + y -= 20 + + // Data rows + for (let rowIdx = 0; rowIdx < records.length; rowIdx++) { + const r = records[rowIdx] + needSpace(18) + + // Alternate row shading + if (rowIdx % 2 === 0) { + page.drawRectangle({ x: ML, y: y - 4, width: contentW, height: 16, color: rgb(0.96, 0.96, 0.97) }) + } + + colX = ML + 4 + + // Date + const dateStr = new Date(r.createdAt).toLocaleString('en-US', { + month: '2-digit', day: '2-digit', year: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }) + drawText(dateStr, colX, y, font, 8, dark, cols[0].w - 6) + colX += cols[0].w + + // Pair + drawText(`${r.fromSymbol} -> ${r.toSymbol}`, colX, y, font, 8, dark, cols[1].w - 6) + colX += cols[1].w + + // Sent + drawText(r.fromAmount, colX, y, font, 8, dark, cols[2].w - 6) + colX += cols[2].w + + // Quoted Out + drawText(r.quotedOutput, colX, y, font, 8, dark, cols[3].w - 6) + colX += cols[3].w + + // Received + const recvColor = r.receivedOutput ? dark : gray + drawText(r.receivedOutput || '-', colX, y, font, 8, recvColor, cols[4].w - 6) + colX += cols[4].w + + // Status + const sColor = STATUS_COLORS[r.status] || gray + drawText(r.status, colX, y, bold, 8, sColor, cols[5].w - 6) + colX += cols[5].w + + // Duration + const durStr = r.actualTimeSeconds !== undefined + ? (r.actualTimeSeconds < 60 ? `${r.actualTimeSeconds}s` : `${Math.floor(r.actualTimeSeconds / 60)}m ${r.actualTimeSeconds % 60}s`) + : '-' + drawText(durStr, colX, y, font, 8, dark, cols[6].w - 6) + colX += cols[6].w + + // Integration + drawText(r.integration, colX, y, font, 8, gray, cols[7].w - 6) + + y -= 16 + } + + // ── Detail Pages (one per swap) ───────────────────────────────── + for (const r of records) { + newPage() + + drawText(`Swap Detail: ${r.fromSymbol} -> ${r.toSymbol}`, ML, y, bold, 14, dark) + y -= 22 + + const sColor = STATUS_COLORS[r.status] || gray + drawText(`Status: ${r.status}`, ML, y, bold, 11, sColor) + y -= 18 + + const details: [string, string][] = [ + ['Date', new Date(r.createdAt).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'], + ['From', `${r.fromAmount} ${r.fromSymbol} (${r.fromAsset})`], + ['To (Quoted)', `${r.quotedOutput} ${r.toSymbol}`], + ['To (Minimum)', `${r.minimumOutput} ${r.toSymbol}`], + ['To (Received)', r.receivedOutput ? `${r.receivedOutput} ${r.toSymbol}` : 'N/A'], + ['Slippage Tolerance', `${r.slippageBps} bps (${(r.slippageBps / 100).toFixed(1)}%)`], + ['Fee', `${r.feeBps} bps`], + ['Outbound Fee', r.feeOutbound], + ['Integration', r.integration], + ['Inbound TX', r.txid], + ['Outbound TX', r.outboundTxid || 'N/A'], + ['Vault Address', r.inboundAddress], + ['Router', r.router || 'N/A'], + ['Est. Time', `${r.estimatedTimeSeconds}s`], + ['Actual Time', r.actualTimeSeconds !== undefined ? `${r.actualTimeSeconds}s` : 'N/A'], + ] + + if (r.approvalTxid) { + details.push(['Approval TX', r.approvalTxid]) + } + + if (r.error) { + details.push(['Error', r.error]) + } + + if (r.memo) { + details.push(['Memo', r.memo]) + } + + for (const [label, value] of details) { + needSpace(16) + drawText(`${label}:`, ML + 6, y, bold, 9, dark) + drawText(value, ML + 120, y, font, 9, label === 'Error' ? STATUS_COLORS.failed : dark, contentW - 130) + y -= 14 + } + + // Quoted vs Received comparison (for completed swaps) + if (r.status === 'completed' && r.receivedOutput && r.quotedOutput) { + y -= 10 + needSpace(40) + const quoted = parseFloat(r.quotedOutput) + const received = parseFloat(r.receivedOutput) + if (quoted > 0 && received > 0) { + const diff = received - quoted + const pctDiff = ((diff / quoted) * 100).toFixed(2) + const diffColor = diff >= 0 ? STATUS_COLORS.completed : STATUS_COLORS.failed + drawText('Quote Accuracy:', ML + 6, y, bold, 10, dark) + y -= 16 + drawText(`Quoted: ${r.quotedOutput} ${r.toSymbol} | Received: ${r.receivedOutput} ${r.toSymbol} | Difference: ${diff > 0 ? '+' : ''}${diff.toFixed(8)} (${pctDiff}%)`, ML + 10, y, font, 9, diffColor) + y -= 14 + } + } + } + + // Final page number + page.drawText(`Page ${pageNum}`, { + x: pageW / 2 - 20, y: 25, font, size: 9, + color: rgb(0.5, 0.5, 0.5), + }) + + const bytes = await doc.save() + return Buffer.from(bytes) +} diff --git a/projects/keepkey-vault/src/bun/swap-tracker.ts b/projects/keepkey-vault/src/bun/swap-tracker.ts new file mode 100644 index 0000000..cb7e533 --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -0,0 +1,452 @@ +/** + * Swap Tracker — monitors pending swaps via Pioneer HTTP polling. + * + * After executeSwap broadcasts a tx, the tracker: + * 1. Registers the swap with Pioneer (CreatePendingSwap) + * 2. Polls Pioneer API (GetPendingSwap per txHash) for status updates + * 3. Pushes status changes to the frontend via RPC messages + * 4. Auto-removes completed/failed swaps after a grace period + * + * Pioneer operationIds used: + * - CreatePendingSwap (POST /swaps/pending) + * - GetPendingSwap (GET /swaps/pending/{txHash}) + */ +import type { PendingSwap, SwapTrackingStatus, SwapStatusUpdate, SwapResult, ExecuteSwapParams, SwapQuote, SwapHistoryRecord } from '../shared/types' +import { getPioneer } from './pioneer' +import { assetToCaip } from './swap-parsing' +import { insertSwapHistory, updateSwapHistoryStatus, getSwapHistory } from './db' + +const TAG = '[swap-tracker]' + +// ── In-memory swap registry ───────────────────────────────────────── + +const pendingSwaps = new Map() +let pollTimer: ReturnType | null = null +let sendMessage: ((msg: string, data: any) => void) | null = null +let pioneerVerified = false + +/** Adaptive polling: fast at first, backs off as swap ages */ +const FAST_POLL_MS = 10_000 // 10s for first 2 minutes +const NORMAL_POLL_MS = 20_000 // 20s for 2-10 minutes +const SLOW_POLL_MS = 30_000 // 30s after 10 minutes +const FAST_PHASE_MS = 2 * 60_000 // 2 min +const NORMAL_PHASE_MS = 10 * 60_000 // 10 min +const COMPLETED_GRACE_MS = 120_000 // keep completed swaps visible for 2 min + +// Required Pioneer SDK methods — app MUST NOT start without these +const REQUIRED_METHODS = ['CreatePendingSwap', 'GetPendingSwap'] as const + +// ── Public API ────────────────────────────────────────────────────── + +/** Initialize the tracker — verifies Pioneer SDK has required methods. Throws on failure. */ +export async function initSwapTracker(messageSender: (msg: string, data: any) => void): Promise { + sendMessage = messageSender + + // FAIL FAST: Verify Pioneer SDK exposes the swap tracking methods + const pioneer = await getPioneer() + const missing: string[] = [] + for (const method of REQUIRED_METHODS) { + if (typeof pioneer[method] !== 'function') { + missing.push(method) + } + } + if (missing.length > 0) { + // Log all available methods for debugging + const available = Object.keys(pioneer).filter(k => typeof pioneer[k] === 'function') + console.error(`${TAG} FATAL: Pioneer SDK missing required methods: ${missing.join(', ')}`) + console.error(`${TAG} Available methods: ${available.join(', ')}`) + throw new Error(`Pioneer SDK missing swap tracking methods: ${missing.join(', ')}. Cannot track swaps.`) + } + + pioneerVerified = true + console.log(`${TAG} Tracker initialized — Pioneer SDK verified (${REQUIRED_METHODS.join(', ')})`) + + // Rehydrate active swaps from SQLite (survives app restart) + try { + const activeStatuses: SwapTrackingStatus[] = ['pending', 'confirming', 'output_detected', 'output_confirming', 'output_confirmed'] + for (const status of activeStatuses) { + const records = getSwapHistory({ status, limit: 50 }) + for (const r of records) { + if (pendingSwaps.has(r.txid)) continue + const swap: PendingSwap = { + txid: r.txid, + fromAsset: r.fromAsset, + toAsset: r.toAsset, + fromSymbol: r.fromSymbol, + toSymbol: r.toSymbol, + fromChainId: r.fromChainId, + toChainId: r.toChainId, + fromAmount: r.fromAmount, + expectedOutput: r.quotedOutput, + memo: r.memo, + inboundAddress: r.inboundAddress, + router: r.router, + integration: r.integration, + status: r.status, + confirmations: 0, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + estimatedTime: r.estimatedTimeSeconds, + } + pendingSwaps.set(r.txid, swap) + } + } + if (pendingSwaps.size > 0) { + console.log(`${TAG} Rehydrated ${pendingSwaps.size} active swap(s) from SQLite`) + startPolling() + } + } catch (e: any) { + console.warn(`${TAG} Failed to rehydrate swaps from SQLite: ${e.message}`) + } +} + +/** Register a newly broadcast swap for tracking */ +export function trackSwap( + result: SwapResult, + params: ExecuteSwapParams, + quote: SwapQuote, +): void { + const now = Date.now() + const swap: PendingSwap = { + txid: result.txid, + fromAsset: params.fromAsset, + toAsset: params.toAsset, + fromSymbol: params.fromAsset.split('.').pop()?.split('-')[0] || params.fromAsset, + toSymbol: params.toAsset.split('.').pop()?.split('-')[0] || params.toAsset, + fromChainId: params.fromChainId, + toChainId: params.toChainId, + fromAmount: params.amount, + expectedOutput: params.expectedOutput, + memo: params.memo, + inboundAddress: params.inboundAddress, + router: params.router, + integration: quote.integration || 'thorchain', + status: 'pending', + confirmations: 0, + createdAt: now, + updatedAt: now, + estimatedTime: quote.estimatedTime, + } + + pendingSwaps.set(result.txid, swap) + console.log(`${TAG} Tracking swap: ${result.txid} (${swap.fromSymbol} → ${swap.toSymbol})`) + + // Persist to SQLite — full lifecycle record + const historyRecord: SwapHistoryRecord = { + id: crypto.randomUUID(), + txid: result.txid, + fromAsset: params.fromAsset, + toAsset: params.toAsset, + fromSymbol: swap.fromSymbol, + toSymbol: swap.toSymbol, + fromChainId: params.fromChainId, + toChainId: params.toChainId, + fromAmount: params.amount, + quotedOutput: quote.expectedOutput || params.expectedOutput, + minimumOutput: quote.minimumOutput || '0', + slippageBps: quote.slippageBps || 300, + feeBps: quote.fees?.totalBps || 0, + feeOutbound: quote.fees?.outbound || '0', + integration: quote.integration || 'thorchain', + memo: params.memo, + inboundAddress: params.inboundAddress, + router: params.router, + status: 'pending', + createdAt: now, + updatedAt: now, + estimatedTimeSeconds: quote.estimatedTime || 0, + approvalTxid: result.approvalTxid, + } + insertSwapHistory(historyRecord) + + // Push immediate update to frontend FIRST (user sees "pending" instantly) + pushUpdate(swap) + + // Register with Pioneer API — log errors but don't block (server processes async) + registerWithPioneer(swap).catch((e) => { + console.error(`${TAG} Pioneer registration FAILED for ${result.txid}: ${e.message}`) + console.error(`${TAG} Stack: ${e.stack}`) + }) + + // Start polling + startPolling() +} + +/** Get all pending swaps (for getPendingSwaps RPC) */ +export function getPendingSwaps(): PendingSwap[] { + return Array.from(pendingSwaps.values()) + .sort((a, b) => b.createdAt - a.createdAt) +} + +/** Dismiss a swap from the tracker (user clicked dismiss) */ +export function dismissSwap(txid: string): void { + pendingSwaps.delete(txid) + // Only stop polling when no ACTIVE swaps remain — don't kill polling for other pending swaps + const hasActive = Array.from(pendingSwaps.values()).some(s => + s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' + ) + if (pendingSwaps.size === 0 || !hasActive) { + stopPolling() + } +} + +/** Convert THORChain asset to CAIP, falling back to the raw string on unsupported chains */ +function safeAssetToCaip(thorAsset: string): string { + try { return assetToCaip(thorAsset) } catch { return thorAsset } +} + +// ── Pioneer REST registration ─────────────────────────────────────── + +async function registerWithPioneer(swap: PendingSwap): Promise { + const pioneer = await getPioneer() + + const body = { + txHash: swap.txid, + addresses: [], + sellAsset: { + caip: safeAssetToCaip(swap.fromAsset), + symbol: swap.fromSymbol, + amount: swap.fromAmount, + amountBaseUnits: swap.fromAmount, + address: swap.inboundAddress || '', + networkId: swap.fromChainId, + }, + buyAsset: { + caip: safeAssetToCaip(swap.toAsset), + symbol: swap.toSymbol, + amount: swap.expectedOutput, + amountBaseUnits: swap.expectedOutput, + address: '', + networkId: swap.toChainId, + }, + quote: { + id: swap.txid, + integration: swap.integration, + expectedAmountOut: swap.expectedOutput, + minimumAmountOut: swap.expectedOutput, + slippage: 3, + fees: { affiliate: '0', protocol: '0', network: '0' }, + memo: swap.memo, + }, + integration: swap.integration, + } + + console.log(`${TAG} CreatePendingSwap request:`, JSON.stringify({ txHash: body.txHash, sellCaip: body.sellAsset.caip, buyCaip: body.buyAsset.caip, integration: body.integration })) + + const resp = await pioneer.CreatePendingSwap(body) + console.log(`${TAG} CreatePendingSwap response:`, JSON.stringify(resp?.data || resp)) + console.log(`${TAG} Registered swap with Pioneer: ${swap.txid}`) +} + +// ── HTTP Polling ──────────────────────────────────────────────────── + +/** Get adaptive poll interval based on oldest active swap age */ +function getPollInterval(): number { + let oldestAge = 0 + for (const swap of pendingSwaps.values()) { + if (swap.status === 'completed' || swap.status === 'failed' || swap.status === 'refunded') continue + const age = Date.now() - swap.createdAt + if (age > oldestAge) oldestAge = age + } + if (oldestAge < FAST_PHASE_MS) return FAST_POLL_MS + if (oldestAge < NORMAL_PHASE_MS) return NORMAL_POLL_MS + return SLOW_POLL_MS +} + +function startPolling(): void { + if (pollTimer) return + // Poll immediately on start, then schedule next + pollAllSwaps().then(scheduleNextPoll) +} + +/** Schedule next poll using setTimeout (prevents stacking if pollAllSwaps is slow) */ +function scheduleNextPoll(): void { + if (pollTimer) clearTimeout(pollTimer) + // Don't schedule if no active swaps remain + const hasActive = Array.from(pendingSwaps.values()).some(s => + s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' + ) + if (pendingSwaps.size === 0 || !hasActive) { + pollTimer = null + return + } + const interval = getPollInterval() + pollTimer = setTimeout(async () => { + await pollAllSwaps() + scheduleNextPoll() + }, interval) +} + +function stopPolling(): void { + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + console.log(`${TAG} Stopped polling (no active swaps)`) + } +} + +/** Apply remote swap data to local swap, push updates if changed */ +function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { + const newStatus = mapPioneerStatus(remoteSwap.status) + const confirmations = remoteSwap.confirmations ?? swap.confirmations + const outboundConfirmations = remoteSwap.outboundConfirmations + const outboundRequiredConfirmations = remoteSwap.outboundRequiredConfirmations + const outboundTxid = remoteSwap.thorchainData?.outboundTxHash + || remoteSwap.mayachainData?.outboundTxHash + || remoteSwap.relayData?.outTxHashes?.[0] + const errorMsg = remoteSwap.error?.userMessage || remoteSwap.error?.message + || (remoteSwap.error ? String(remoteSwap.error) : undefined) + const timeEstimate = remoteSwap.timeEstimate + + const changed = + newStatus !== swap.status || + confirmations !== swap.confirmations || + (outboundConfirmations !== undefined && outboundConfirmations !== swap.outboundConfirmations) || + (outboundTxid && outboundTxid !== swap.outboundTxid) + + if (changed) { + swap.status = newStatus + swap.updatedAt = Date.now() + swap.confirmations = confirmations + if (outboundConfirmations !== undefined) swap.outboundConfirmations = outboundConfirmations + if (outboundRequiredConfirmations !== undefined) swap.outboundRequiredConfirmations = outboundRequiredConfirmations + if (outboundTxid) swap.outboundTxid = outboundTxid + if (errorMsg) swap.error = errorMsg + + if (timeEstimate?.total_swap_seconds && timeEstimate.total_swap_seconds > 0) { + swap.estimatedTime = timeEstimate.total_swap_seconds + } + + const receivedOutput = (remoteSwap.buyAsset?.amount && parseFloat(remoteSwap.buyAsset.amount) > 0) + ? remoteSwap.buyAsset.amount + : undefined + if (receivedOutput) { + swap.expectedOutput = receivedOutput + } + + console.log(`${TAG} Status change: ${swap.txid} → ${newStatus} (confirmations=${confirmations}, outbound=${outboundConfirmations || 0}/${outboundRequiredConfirmations || '?'}, outTxid=${outboundTxid || 'none'})`) + + // Persist status change to SQLite + const isFinal = newStatus === 'completed' || newStatus === 'failed' || newStatus === 'refunded' + const now = Date.now() + updateSwapHistoryStatus(swap.txid, newStatus, { + outboundTxid: outboundTxid || undefined, + error: errorMsg || undefined, + receivedOutput, + completedAt: isFinal ? now : undefined, + actualTimeSeconds: isFinal ? Math.round((now - swap.createdAt) / 1000) : undefined, + }) + + pushUpdate(swap) + + if (isFinal) { + pushComplete(swap) + } + } +} + +async function pollAllSwaps(): Promise { + const active = Array.from(pendingSwaps.values()).filter(s => + s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' + ) + + if (active.length === 0) { + // Clean up completed swaps past grace period + const now = Date.now() + for (const [txid, swap] of pendingSwaps) { + if ((swap.status === 'completed' || swap.status === 'failed' || swap.status === 'refunded') && + now - swap.updatedAt > COMPLETED_GRACE_MS) { + pendingSwaps.delete(txid) + } + } + if (pendingSwaps.size === 0) { + stopPolling() + } + return + } + + const pioneer = await getPioneer() + + console.log(`${TAG} Polling ${active.length} active swap(s) via GetPendingSwap (per-txHash)...`) + + // Poll each swap individually — GetPendingSwap uses /swaps/pending/{txHash} + // which doesn't conflict with the SwapHistoryController route + for (const swap of active) { + try { + // GetPendingSwap expects txHash as a path parameter + // pioneer-client for GET: first arg = parameters (mapped to spec params) + const resp = await pioneer.GetPendingSwap({ txHash: swap.txid }) + const remoteSwap = resp?.data || resp + + if (!remoteSwap || remoteSwap.status === 'not_found') { + console.log(`${TAG} Swap ${swap.txid.slice(0, 10)}... not found in Pioneer yet`) + continue + } + + console.log(`${TAG} GetPendingSwap ${swap.txid.slice(0, 10)}...: status=${remoteSwap.status}, confirmations=${remoteSwap.confirmations || 0}`) + + applyRemoteSwapData(swap, remoteSwap) + + // When swap just completed and we don't have outbound txid, do a rescan to get it + if (swap.status === 'completed' && !swap.outboundTxid) { + try { + console.log(`${TAG} Swap completed but no outbound txid — requesting rescan...`) + const rescanResp = await pioneer.GetPendingSwap({ txHash: swap.txid, rescan: true }) + const rescanData = rescanResp?.data || rescanResp + if (rescanData && rescanData.status !== 'not_found') { + applyRemoteSwapData(swap, rescanData) + } + } catch (e: any) { + console.warn(`${TAG} Rescan failed for ${swap.txid.slice(0, 10)}...: ${e.message}`) + } + } + } catch (e: any) { + // 404 is expected for newly created swaps that haven't been indexed yet + if (e.status === 404 || e.statusCode === 404 || e.message?.includes('404')) { + console.log(`${TAG} Swap ${swap.txid.slice(0, 10)}... not indexed yet (404)`) + } else { + console.error(`${TAG} GetPendingSwap FAILED for ${swap.txid.slice(0, 10)}...: ${e.message}`) + } + } + } +} + +function mapPioneerStatus(status: string): SwapTrackingStatus { + const map: Record = { + pending: 'pending', + confirming: 'confirming', + output_detected: 'output_detected', + output_confirming: 'output_confirming', + output_confirmed: 'output_confirmed', + completed: 'completed', + failed: 'failed', + refunded: 'refunded', + } + return map[status] || 'pending' +} + +// ── RPC message pushing ───────────────────────────────────────────── + +function pushUpdate(swap: PendingSwap): void { + if (!sendMessage) { + console.warn(`${TAG} sendMessage not initialized — cannot push swap-update`) + return + } + const update: SwapStatusUpdate = { + txid: swap.txid, + status: swap.status, + confirmations: swap.confirmations, + outboundConfirmations: swap.outboundConfirmations, + outboundRequiredConfirmations: swap.outboundRequiredConfirmations, + outboundTxid: swap.outboundTxid, + error: swap.error, + } + console.log(`${TAG} Pushing swap-update: ${swap.txid} status=${swap.status} confirmations=${swap.confirmations}`) + sendMessage('swap-update', update) +} + +function pushComplete(swap: PendingSwap): void { + if (!sendMessage) return + console.log(`${TAG} Pushing swap-complete: ${swap.txid} status=${swap.status}`) + sendMessage('swap-complete', swap) +} diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts new file mode 100644 index 0000000..df86f7e --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -0,0 +1,606 @@ +/** + * Swap service — Pioneer API integration for cross-chain swaps. + * + * ALL swap data flows through Pioneer (api.keepkey.info): + * - Available assets: Pioneer GetAvailableAssets + * - Quotes: Pioneer Quote (aggregates THORChain, ShapeShift, ChainFlip, etc.) + * - Execution: builds, signs (on device), and broadcasts swap txs + * + * NO direct THORNode or other third-party calls — fail fast if Pioneer is down. + */ +import { CHAINS } from '../shared/chains' +import type { ChainDef } from '../shared/chains' +import type { SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult } from '../shared/types' +import { getPioneer } from './pioneer' +import { encodeDepositWithExpiry, encodeApprove, parseUnits, toHex } from './txbuilder/evm' +import { getEvmGasPrice, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20Decimals, broadcastEvmTx, waitForTxReceipt, estimateGas } from './evm-rpc' +import * as txb from './txbuilder' +// Re-export pure parsing functions (used by tests + this module) +export { parseQuoteResponse, parseAssetsResponse, parseThorAsset, assetToCaip, THOR_TO_CHAIN } from './swap-parsing' +import { parseQuoteResponse, parseAssetsResponse, assetToCaip } from './swap-parsing' + +const TAG = '[swap]' + +/** Known THORChain router contracts per EVM chain — verified against THORNode */ +const THORCHAIN_ROUTERS: Record = { + ethereum: ['0xd37bbe5744d730a1d98d8dc97c42f0ca46ad7146', '0x42a5ed456650a09dc10ebc6361a7480fdd61f27b'], + avalanche: ['0x8f66c4ae756bebc49ec8b81966dd8bba9f127549'], + bsc: ['0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b'], + base: ['0x1b3e6daa08e7a2e29e2ff23b6c40abe79a15a17a'], +} + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +/** Format a bigint wei value as a human-readable string (avoids Number() precision loss for large values) */ +function formatWei(wei: bigint, decimals = 18): string { + const whole = wei / 10n ** BigInt(decimals) + const frac = wei % 10n ** BigInt(decimals) + const fracStr = frac.toString().padStart(decimals, '0').slice(0, 6).replace(/0+$/, '') + return fracStr ? `${whole}.${fracStr}` : `${whole}` +} + +/** Chain-aware minimum gas price fallbacks (gwei) — used when RPC/Pioneer both fail */ +const MIN_GAS_GWEI: Record = { + ethereum: 10, + polygon: 30, + avalanche: 25, + bsc: 3, + base: 0.01, + arbitrum: 0.01, + optimism: 0.01, +} + +/** Chain-aware gas limits for depositWithExpiry — L2s need more for L1 data posting */ +const DEPOSIT_GAS_LIMITS: Record = { + ethereum: 120000n, + polygon: 120000n, + avalanche: 120000n, + bsc: 120000n, + base: 200000n, + arbitrum: 300000n, // Arbitrum gas units != mainnet gas units + 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 + +// ── Pool/Asset fetching via Pioneer ───────────────────────────────── + +let assetCache: SwapAsset[] = [] +let assetCacheTime = 0 +const ASSET_CACHE_TTL = 5 * 60_000 // 5 minutes + +/** Invalidate the asset cache (e.g., after Pioneer reconnects) */ +export function clearSwapCache(): void { + assetCache = [] + assetCacheTime = 0 +} + +/** Fetch available swap assets from Pioneer GetAvailableAssets */ +export async function getSwapAssets(): Promise { + if (assetCache.length > 0 && Date.now() - assetCacheTime < ASSET_CACHE_TTL) { + return assetCache + } + + const pioneer = await getPioneer() + console.log(`${TAG} Fetching available swap assets from Pioneer...`) + + const resp = await pioneer.GetAvailableAssets() + const assets = parseAssetsResponse(resp) + + // Ensure RUNE is always included (may not be in pools list) + if (!assets.find(a => a.asset === 'THOR.RUNE')) { + const thorDef = CHAINS.find(c => c.id === 'thorchain') + if (thorDef) { + assets.unshift({ + asset: 'THOR.RUNE', + chainId: 'thorchain', + symbol: 'RUNE', + name: 'THORChain', + chainFamily: 'cosmos', + decimals: 8, + caip: thorDef.caip, + }) + } + } + + console.log(`${TAG} Loaded ${assets.length} swap assets from Pioneer`) + assetCache = assets + assetCacheTime = Date.now() + return assets +} + +// ── Quote fetching via Pioneer ────────────────────────────────────── + +/** Fetch a swap quote from Pioneer (aggregated across DEXes) */ +export async function getSwapQuote(params: SwapQuoteParams): Promise { + if (!params.amount || parseFloat(params.amount) <= 0) { + throw new Error('Amount must be greater than 0') + } + + const pioneer = await getPioneer() + + // Convert THORChain asset notation to CAIP for Pioneer Quote + const sellCaip = assetToCaip(params.fromAsset) + const buyCaip = assetToCaip(params.toAsset) + const slippage = params.slippageBps ? params.slippageBps / 100 : 3 // Pioneer uses % not bps + + // Normalize BCH CashAddr: strip "bitcoincash:" prefix — THORChain uses short form + const normalizeBchAddr = (addr: string) => + addr.startsWith('bitcoincash:') ? addr.slice('bitcoincash:'.length) : addr + const senderAddress = normalizeBchAddr(params.fromAddress) + const recipientAddress = normalizeBchAddr(params.toAddress) + + console.log(`${TAG} Fetching quote: ${params.fromAsset} → ${params.toAsset} (${params.amount})`) + console.log(`${TAG} CAIP: ${sellCaip} → ${buyCaip}`) + console.log(`${TAG} sender=${senderAddress}, recipient=${recipientAddress}`) + + const quoteResp = await pioneer.Quote({ + sellAsset: sellCaip, + sellAmount: params.amount, // Pioneer expects DECIMAL format (human-readable) + buyAsset: buyCaip, + recipientAddress, + senderAddress, + slippage, + }) + + // Log raw response structure for debugging quote parsing issues + const qDebug = quoteResp?.data?.data || quoteResp?.data || quoteResp + const firstQuote = Array.isArray(qDebug) ? qDebug[0] : qDebug + console.log(`${TAG} Raw quote response keys: ${firstQuote ? Object.keys(firstQuote).join(', ') : 'EMPTY'}`) + + const result = parseQuoteResponse(quoteResp, params) + console.log(`${TAG} Quote: ${result.expectedOutput} (via ${result.integration}), memo=${result.memo || 'NONE'}, router=${result.router || 'NONE'}, expiry=${result.expiry}`) + return result +} + +// ── Swap execution ────────────────────────────────────────────────── + +/** Wallet methods used during swap execution (subset of hdwallet interface) */ +export interface SwapWallet { + getPublicKeys(params: any[]): Promise | null> + ethSignTx(params: any): Promise + [method: string]: (...args: any[]) => Promise // dynamic address/sign methods +} + +/** Dependencies injected by the caller (index.ts) to avoid circular imports */ +export interface SwapContext { + wallet: SwapWallet + getAllChains: () => ChainDef[] + getRpcUrl: (chain: ChainDef) => string | undefined + getBtcXpub: () => string | undefined // selected BTC xpub if available +} + +/** Execute a swap: build tx, sign on device, broadcast */ +export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): Promise { + const { wallet, getAllChains, getRpcUrl, getBtcXpub } = ctx + + // Resolve source chain + const allChains = getAllChains() + const fromChain = allChains.find(c => c.id === params.fromChainId) + if (!fromChain) throw new Error(`Unknown source chain: ${params.fromChainId}`) + + // Detect ERC-20 source (THORChain format: "ETH.USDT-0xDAC17F..." — has hyphen + contract) + const isErc20Source = params.fromAsset.includes('-') && fromChain.chainFamily === 'evm' + + // 1. Get sender address + const addrParams: any = { + addressNList: fromChain.defaultPath, + showDisplay: false, + coin: fromChain.chainFamily === 'evm' ? 'Ethereum' : fromChain.coin, + } + if (fromChain.scriptType) addrParams.scriptType = fromChain.scriptType + const addrMethod = fromChain.id === 'ripple' ? 'rippleGetAddress' : fromChain.rpcMethod + const addrResult = await wallet[addrMethod](addrParams) + const fromAddress = typeof addrResult === 'string' ? addrResult : addrResult?.address + if (!fromAddress) throw new Error('Could not derive sender address') + + // 1b. Derive destination address for validation + const toChain = allChains.find(c => c.id === params.toChainId) + if (!toChain) throw new Error(`Unknown destination chain: ${params.toChainId}`) + + const toAddrParams: any = { + addressNList: toChain.defaultPath, + showDisplay: false, + coin: toChain.chainFamily === 'evm' ? 'Ethereum' : toChain.coin, + } + if (toChain.scriptType) toAddrParams.scriptType = toChain.scriptType + const toAddrMethod = toChain.id === 'ripple' ? 'rippleGetAddress' : toChain.rpcMethod + const toAddrResult = await wallet[toAddrMethod](toAddrParams) + const toAddress = typeof toAddrResult === 'string' ? toAddrResult : toAddrResult?.address + if (!toAddress) throw new Error('Could not derive destination address') + + // SAFETY: Reject memos containing extended pubkeys — these are never valid destinations + // and indicate the quote was fetched with an unresolved xpub address. + // Covers: xpub/ypub/zpub (BTC), dgub (DOGE), Ltub/Mtub (LTC), drkp/drks (DASH), tpub (testnet) + if (params.memo && /(xpub|ypub|zpub|dgub|Ltub|Mtub|drkp|drks|tpub|upub|vpub)[a-zA-Z0-9]{20,}/.test(params.memo)) { + throw new Error('Swap memo contains an extended pubkey instead of a destination address — aborting to protect funds') + } + + // Validate the memo contains our destination address (only for UTXO/Cosmos — EVM memos use shorthand/aggregator formats) + // Normalize BCH CashAddr: strip "bitcoincash:" prefix for comparison — THORChain memos use short form + const toAddrNorm = toAddress.startsWith('bitcoincash:') ? toAddress.slice('bitcoincash:'.length) : toAddress + if (params.memo && fromChain.chainFamily !== 'evm' && !params.memo.toLowerCase().includes(toAddrNorm.toLowerCase())) { + console.warn(`${TAG} WARNING: Swap memo does not contain derived destination address. Memo may use a different format.`) + } + + // 2. Validate required fields + if (!params.inboundAddress) throw new Error('Missing inbound vault address from quote') + if (!params.memo) throw new Error('Missing swap memo from quote') + const memoByteLength = Buffer.byteLength(params.memo, 'utf8') + if (memoByteLength > MEMO_LIMIT) { + throw new Error(`Swap memo too long (${memoByteLength} bytes, THORChain max ${MEMO_LIMIT})`) + } + + console.log(`${TAG} Executing: ${params.fromAsset} → ${params.toAsset}, amount=${params.amount}`) + console.log(`${TAG} Chain family: ${fromChain.chainFamily}, vault: ${params.inboundAddress}, router: ${params.router || 'none'}`) + if (isErc20Source) console.log(`${TAG} ERC-20 source detected: ${params.fromAsset}`) + + // 3. Get Pioneer for tx building + const pioneer = await getPioneer() + + let unsignedTx: any + let approvalTxid: string | undefined + + // ── EVM chains: MUST use router contract depositWithExpiry() ── + if (fromChain.chainFamily === 'evm') { + const result = await buildEvmSwapTx(params, fromChain, fromAddress, pioneer, getRpcUrl, isErc20Source, wallet) + unsignedTx = result.unsignedTx + approvalTxid = result.approvalTxid + + // ── UTXO chains: send to vault, memo in OP_RETURN ── + } else if (fromChain.chainFamily === 'utxo') { + // Only use BTC multi-account xpub for Bitcoin — other UTXO chains (DOGE, LTC, etc.) + // have their own xpub formats and must derive their own + let xpub: string | undefined + if (fromChain.id === 'bitcoin') { + try { xpub = getBtcXpub() } catch { /* BTC account manager not ready */ } + if (!xpub) { + console.warn(`${TAG} BTC multi-account xpub unavailable — falling back to default account 0`) + } + } + if (!xpub) { + try { + const result = await wallet.getPublicKeys([{ + addressNList: fromChain.defaultPath.slice(0, 3), + coin: fromChain.coin, + scriptType: fromChain.scriptType, + curve: 'secp256k1', + }]) + xpub = result?.[0]?.xpub + } catch (e: any) { + throw new Error(`Failed to get xpub for ${fromChain.coin}: ${e.message}`) + } + } + + const buildResult = await txb.buildTx(pioneer, fromChain, { + chainId: fromChain.id, + to: params.inboundAddress, + amount: params.amount, + memo: params.memo, + feeLevel: params.feeLevel, + isMax: params.isMax, + fromAddress, + xpub, + }) + unsignedTx = buildResult.unsignedTx + + // ── Cosmos/THORChain: send to vault with memo in tx metadata ── + } else { + const buildResult = await txb.buildTx(pioneer, fromChain, { + chainId: fromChain.id, + to: params.inboundAddress, + amount: params.amount, + memo: params.memo, + feeLevel: params.feeLevel, + isMax: params.isMax, + isSwapDeposit: true, // C1 fix: explicit flag for MsgDeposit (not inferred from memo) + fromAddress, + }) + unsignedTx = buildResult.unsignedTx + } + + // 4. Sign on device (user confirms tx details on hardware wallet) + const signedTx = await txb.signTx(wallet, fromChain, unsignedTx) + + // 5. Broadcast + const { txid } = await txb.broadcastTx(pioneer, fromChain, signedTx) + + console.log(`${TAG} Broadcast success: ${txid}`) + + return { + txid, + fromAsset: params.fromAsset, + toAsset: params.toAsset, + fromAmount: params.amount, + expectedOutput: params.expectedOutput, + ...(approvalTxid ? { approvalTxid } : {}), + } +} + +// ── EVM swap tx building (extracted for readability) ──────────────── + +async function buildEvmSwapTx( + params: ExecuteSwapParams, + fromChain: ChainDef, + fromAddress: string, + pioneer: any, + getRpcUrl: (chain: ChainDef) => string | undefined, + isErc20Source: boolean, + wallet: any, +): Promise<{ unsignedTx: any; approvalTxid?: string }> { + if (!params.router) throw new Error('EVM swaps require a router address from the quote') + + // Validate router against known THORChain routers (warn-only, routers rotate during churn) + const knownRouters = THORCHAIN_ROUTERS[fromChain.id] + if (knownRouters && knownRouters.length > 0) { + const routerLower = params.router.toLowerCase() + if (!knownRouters.some(r => r.toLowerCase() === routerLower)) { + console.warn(`${TAG} Router ${params.router} not in known list for ${fromChain.id}. Proceeding — routers rotate during vault churn.`) + } + } + + // Use expiry from quote if available, otherwise 1 hour from now + const expiry = params.expiry && params.expiry > Math.floor(Date.now() / 1000) + ? params.expiry + : Math.floor(Date.now() / 1000) + 3600 + const chainId = parseInt(fromChain.chainId || '1', 10) + const rpcUrl = getRpcUrl(fromChain) + + // Fetch gas price, nonce, native balance + const fallbackGwei = MIN_GAS_GWEI[fromChain.id] ?? 10 + const fallbackGasPrice = BigInt(Math.round(fallbackGwei * 1e9)) + let gasPrice: bigint + if (rpcUrl) { + try { gasPrice = await getEvmGasPrice(rpcUrl) } catch (e: any) { + console.warn(`${TAG} Failed to fetch gas price via RPC, using ${fallbackGwei} gwei fallback for ${fromChain.id}: ${e.message}`) + gasPrice = fallbackGasPrice + } + } else { + try { + const gp = await pioneer.GetGasPriceByNetwork({ networkId: fromChain.networkId }) + const gpData = gp?.data + const gpGwei = typeof gpData === 'object' ? parseFloat(gpData.average || gpData.fast || String(fallbackGwei)) : parseFloat(gpData || String(fallbackGwei)) + gasPrice = BigInt(Math.round((isNaN(gpGwei) ? fallbackGwei : gpGwei) * 1e9)) + } catch (e: any) { + console.warn(`${TAG} Failed to fetch gas price via Pioneer, using ${fallbackGwei} gwei fallback for ${fromChain.id}: ${e.message}`) + gasPrice = fallbackGasPrice + } + } + if (params.feeLevel != null && params.feeLevel <= 2) gasPrice = gasPrice * 80n / 100n + else if (params.feeLevel != null && params.feeLevel >= 8) gasPrice = gasPrice * 150n / 100n + + let nonce: number | undefined + if (rpcUrl) { + try { nonce = await getEvmNonce(rpcUrl, fromAddress) } catch (e: any) { + console.warn(`${TAG} Failed to fetch nonce via RPC: ${e.message}`) + } + } + if (nonce === undefined) { + try { + const nd = await pioneer.GetNonceByNetwork({ networkId: fromChain.networkId, address: fromAddress }) + nonce = nd?.data?.nonce + } catch (e: any) { + console.warn(`${TAG} Failed to fetch nonce via Pioneer: ${e.message}`) + } + } + if (nonce === undefined || nonce === null) { + throw new Error(`Failed to fetch nonce for ${fromAddress} on ${fromChain.id} — cannot safely build swap transaction`) + } + + let nativeBalance = 0n + if (rpcUrl) { + try { nativeBalance = await getEvmBalance(rpcUrl, fromAddress) } catch (e: any) { + console.warn(`${TAG} Failed to fetch native balance via RPC: ${e.message}`) + } + } else { + try { + const bd = await pioneer.GetBalanceAddressByNetwork({ networkId: fromChain.networkId, address: fromAddress }) + const balEth = parseFloat(bd?.data?.nativeBalance || bd?.data?.balance || '0') + nativeBalance = BigInt(Math.round(balEth * 1e18)) + } catch (e: any) { + console.warn(`${TAG} Failed to fetch balance via Pioneer: ${e.message}`) + } + } + + let approvalTxid: string | undefined + + if (isErc20Source) { + // ── ERC-20 source swap: approve + depositWithExpiry ── + + // a) Extract token contract from THORChain asset string "ETH.USDT-0xDAC17F..." + const assetParts = params.fromAsset.split('-') + const tokenContract = assetParts.slice(1).join('-') // rejoin in case of multiple hyphens + if (!tokenContract || !tokenContract.startsWith('0x')) { + throw new Error(`Cannot extract token contract from asset: ${params.fromAsset}`) + } + + // b) Get token decimals (direct RPC first, then Pioneer fallback) + let tokenDecimals = 18 + if (rpcUrl) { + try { + tokenDecimals = await getErc20Decimals(rpcUrl, tokenContract) + console.log(`${TAG} Token decimals (direct RPC): ${tokenDecimals}`) + } catch (e: any) { + console.warn(`${TAG} Direct RPC decimals failed: ${e.message}, trying Pioneer...`) + try { + const decimalsResp = await pioneer.GetTokenDecimals({ networkId: fromChain.networkId, contractAddress: tokenContract }) + tokenDecimals = Number(decimalsResp?.data?.decimals) + if (isNaN(tokenDecimals) || tokenDecimals < 0 || tokenDecimals > 36) tokenDecimals = 18 + } catch { console.warn(`${TAG} Pioneer decimals also failed, using default 18`) } + } + } else { + try { + const decimalsResp = await pioneer.GetTokenDecimals({ networkId: fromChain.networkId, contractAddress: tokenContract }) + tokenDecimals = Number(decimalsResp?.data?.decimals) + if (isNaN(tokenDecimals) || tokenDecimals < 0 || tokenDecimals > 36) tokenDecimals = 18 + } catch { console.warn(`${TAG} Pioneer decimals failed, using default 18`) } + } + + // c) Parse amount using TOKEN decimals (not chain's native 18) + const amountBaseUnits = parseUnits(params.amount, tokenDecimals) + console.log(`${TAG} ERC-20 amount: ${amountBaseUnits} base units (${tokenDecimals} decimals)`) + + // Validate native balance covers gas for approve + deposit + const approveGasLimit = 80000n + const depositGasLimit = 200000n + const totalGas = gasPrice * (approveGasLimit + depositGasLimit) + if (nativeBalance < totalGas) { + throw new Error( + `Insufficient ${fromChain.symbol} for gas: need ~${formatWei(totalGas)}, ` + + `have ${formatWei(nativeBalance)}` + ) + } + + // d) Check allowance + let needsApproval = true + if (rpcUrl) { + try { + const currentAllowance = await getErc20Allowance(rpcUrl, tokenContract, fromAddress, params.router) + needsApproval = currentAllowance < amountBaseUnits + console.log(`${TAG} Current allowance: ${currentAllowance}, needed: ${amountBaseUnits}, needsApproval: ${needsApproval}`) + } catch (e: any) { + console.warn(`${TAG} Allowance check failed, assuming approval needed: ${e.message}`) + } + } + + // e) If allowance insufficient, sign + broadcast approve tx + // H2 fix: approve exact amount (not MaxUint256) — safer for hardware wallet users + if (needsApproval) { + const approveData = encodeApprove(params.router, amountBaseUnits) + + const approveTx = { + chainId, + addressNList: fromChain.defaultPath, + nonce: toHex(nonce), + gasLimit: toHex(approveGasLimit), + gasPrice: toHex(gasPrice), + to: tokenContract, // approve is called on the token contract + value: '0x0', // no ETH value + data: approveData, + } + + console.log(`${TAG} Signing ERC-20 approve tx: token=${tokenContract}, spender=${params.router}, amount=${amountBaseUnits}`) + const signedApprove = await wallet.ethSignTx(approveTx) + + // Extract serialized tx + let approveHex: string + if (typeof signedApprove === 'string') { + approveHex = signedApprove + } else if (signedApprove?.serializedTx) { + approveHex = signedApprove.serializedTx + } else if (signedApprove?.serialized) { + approveHex = signedApprove.serialized + } else { + throw new Error('Failed to extract serialized approve tx') + } + if (!approveHex.startsWith('0x')) approveHex = '0x' + approveHex + + // Broadcast approve tx + if (rpcUrl) { + approvalTxid = await broadcastEvmTx(rpcUrl, approveHex) + console.log(`${TAG} Approve tx broadcast (direct RPC): ${approvalTxid}`) + + // Wait for approval receipt before building deposit — prevents nonce gap if approval reverts + console.log(`${TAG} Waiting for approval receipt (up to 90s)...`) + const receipt = await waitForTxReceipt(rpcUrl, approvalTxid, 90_000) + if (receipt && !receipt.status) { + throw new Error(`ERC-20 approve tx reverted on-chain (txid: ${approvalTxid}). Swap aborted — no deposit was sent.`) + } + if (!receipt) { + console.warn(`${TAG} Approval receipt not confirmed within 90s — proceeding with deposit (nonce gap risk)`) + } else { + console.log(`${TAG} Approval confirmed on-chain (gas used: ${receipt.gasUsed})`) + } + } else { + const approveResult = await pioneer.Broadcast({ networkId: fromChain.networkId, serialized: approveHex }) + approvalTxid = approveResult?.data?.txid || approveResult?.data?.tx_hash || approveResult?.data?.hash + console.log(`${TAG} Approve tx broadcast (Pioneer): ${approvalTxid}`) + // No receipt check available without RPC — warn user + console.warn(`${TAG} No direct RPC — cannot verify approval receipt. Proceeding with deposit.`) + } + + nonce += 1 + } + + // f) Build depositWithExpiry with token contract as asset, value = 0x0 + const depositData = encodeDepositWithExpiry( + params.inboundAddress, // vault address + tokenContract, // ERC-20 token contract (NOT zero address) + amountBaseUnits, + params.memo, + expiry, + ) + + // Dynamic gas estimation with static fallback + let erc20DepositGas = depositGasLimit + if (rpcUrl) { + erc20DepositGas = await estimateGas(rpcUrl, { + to: params.router, from: fromAddress, data: depositData, value: '0x0', + }, depositGasLimit) + console.log(`${TAG} Estimated deposit gas: ${erc20DepositGas} (fallback: ${depositGasLimit})`) + } + + const unsignedTx = { + chainId, + addressNList: fromChain.defaultPath, + nonce: toHex(nonce), + gasLimit: toHex(erc20DepositGas), + gasPrice: toHex(gasPrice), + to: params.router, // ROUTER contract, NOT vault + value: '0x0', // no ETH value for ERC-20 swaps + data: depositData, + } + + console.log(`${TAG} ERC-20 router call: to=${params.router}, vault=${params.inboundAddress}, token=${tokenContract}, amount=${amountBaseUnits}`) + return { unsignedTx, approvalTxid } + + } else { + // ── Native asset swap: asset = 0x0, value = amountWei ── + const amountWei = parseUnits(params.amount, fromChain.decimals) + const staticGasLimit = DEPOSIT_GAS_LIMITS[fromChain.id] || 120000n + + const data = encodeDepositWithExpiry( + params.inboundAddress, // vault address + ZERO_ADDRESS, // native asset (not ERC-20) + amountWei, + params.memo, + expiry, + ) + + // Dynamic gas estimation with static fallback + let gasLimit = staticGasLimit + if (rpcUrl) { + gasLimit = await estimateGas(rpcUrl, { + to: params.router, from: fromAddress, data, value: toHex(amountWei), + }, staticGasLimit) + console.log(`${TAG} Estimated native deposit gas: ${gasLimit} (fallback: ${staticGasLimit})`) + } + + const gasFee = gasPrice * gasLimit + + if (nativeBalance < amountWei + gasFee) { + throw new Error( + `Insufficient ${fromChain.symbol}: need ${formatWei(amountWei + gasFee)}, ` + + `have ${formatWei(nativeBalance)}` + ) + } + + const unsignedTx = { + chainId, + addressNList: fromChain.defaultPath, + nonce: toHex(nonce), + gasLimit: toHex(gasLimit), + gasPrice: toHex(gasPrice), + to: params.router, // ROUTER contract, NOT vault + value: toHex(amountWei), // ETH value sent with the call + data, // depositWithExpiry encoded call + } + + console.log(`${TAG} EVM native router call: to=${params.router}, vault=${params.inboundAddress}, value=${params.amount} ${fromChain.symbol}`) + return { unsignedTx } + } +} diff --git a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts index 12b9f8b..bf9dd73 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts @@ -25,14 +25,26 @@ const FEES: Record = { osmosis: 0.035, } -// Chain-specific msg types -const MSG_TYPES: Record = { +// Chain-specific msg types (MsgSend) +const MSG_SEND_TYPES: Record = { thorchain: 'thorchain/MsgSend', mayachain: 'mayachain/MsgSend', cosmos: 'cosmos-sdk/MsgSend', osmosis: 'cosmos-sdk/MsgSend', } +// Chain-specific msg types (MsgDeposit — used for swaps/LP on THORChain/Maya) +const MSG_DEPOSIT_TYPES: Record = { + thorchain: 'thorchain/MsgDeposit', + mayachain: 'mayachain/MsgDeposit', +} + +// MsgDeposit asset identifiers (CHAIN.SYMBOL format) +const DEPOSIT_ASSETS: Record = { + thorchain: 'THOR.RUNE', + mayachain: 'MAYA.CACAO', +} + // Fee templates const FEE_TEMPLATES: Record = { thorchain: { gas: '500000000', amount: [{ denom: 'rune', amount: '0' }] }, @@ -45,7 +57,9 @@ export interface BuildCosmosParams { to: string amount: string // human-readable (e.g. "1.5") memo?: string + feeLevel?: number // 1=slow, 5=fast (default 5) isMax?: boolean + isSwapDeposit?: boolean // use MsgDeposit instead of MsgSend (for THORChain/Maya swaps) fromAddress: string } @@ -54,7 +68,7 @@ export async function buildCosmosTx( chain: ChainDef, params: BuildCosmosParams, ) { - const { to, memo = '', isMax = false, fromAddress } = params + const { to, memo = '', feeLevel = 5, isMax = false, isSwapDeposit = false, fromAddress } = params const denom = chain.denom || chain.symbol.toLowerCase() @@ -95,30 +109,56 @@ export async function buildCosmosTx( if (baseAmount <= 0n) throw new Error('Amount must be greater than zero') - // 3. Build unsigned tx - const fee = FEE_TEMPLATES[chain.id] || FEE_TEMPLATES.cosmos - const msgType = MSG_TYPES[chain.id] || 'cosmos-sdk/MsgSend' + // 3. Build unsigned tx — apply feeLevel multiplier to gas + const baseFee = FEE_TEMPLATES[chain.id] || FEE_TEMPLATES.cosmos + const gasMultiplier = feeLevel <= 2 ? 1 : feeLevel <= 4 ? 1.5 : 2 + const adjustedGas = String(Math.ceil(Number(baseFee.gas) * gasMultiplier)) + const adjustedFeeAmount = baseFee.amount.map(a => ({ + ...a, + amount: String(Math.ceil(Number(a.amount) * gasMultiplier)), + })) + const fee = { gas: adjustedGas, amount: adjustedFeeAmount } if (!chain.chainId) throw new Error(`Missing chainId for Cosmos chain: ${chain.id}`) const chain_id = chain.chainId const feeInDisplay = String(Number(fee.amount[0]?.amount || 0) / 10 ** chain.decimals) + // Determine message type: MsgDeposit for THORChain/Maya swaps (explicit flag), MsgSend otherwise + // NOTE: Do NOT infer from !!memo — normal sends with memos (e.g. exchange deposits) must use MsgSend + const isDeposit = isSwapDeposit && (chain.id === 'thorchain' || chain.id === 'mayachain') + let msg: { type: string; value: Record } + + if (isDeposit) { + const depositType = MSG_DEPOSIT_TYPES[chain.id]! + const depositAsset = DEPOSIT_ASSETS[chain.id]! + console.log(`${TAG} Building MsgDeposit: asset=${depositAsset}, amount=${baseAmount}, memo=${memo}`) + msg = { + type: depositType, + value: { + coins: [{ asset: depositAsset, amount: String(baseAmount) }], + memo, + signer: fromAddress, + }, + } + } else { + const sendType = MSG_SEND_TYPES[chain.id] || 'cosmos-sdk/MsgSend' + msg = { + type: sendType, + value: { + amount: [{ denom, amount: String(baseAmount) }], + from_address: fromAddress, + to_address: to, + }, + } + } + return { signerAddress: fromAddress, addressNList: chain.defaultPath, tx: { fee, memo: memo || '', - msg: [ - { - type: msgType, - value: { - amount: [{ denom, amount: String(baseAmount) }], - from_address: fromAddress, - to_address: to, - }, - }, - ], + msg: [msg], signatures: [], }, chain_id, diff --git a/projects/keepkey-vault/src/bun/txbuilder/evm.ts b/projects/keepkey-vault/src/bun/txbuilder/evm.ts index b94700b..3283339 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/evm.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/evm.ts @@ -11,18 +11,26 @@ import { getEvmGasPrice, getEvmNonce, getEvmBalance } from '../evm-rpc' const TAG = '[txbuilder:evm]' /** String-based decimal→BigInt to avoid floating-point precision loss */ -function parseUnits(amount: string, decimals: number): bigint { +export function parseUnits(amount: string, decimals: number): bigint { const [whole = '0', frac = ''] = amount.split('.') const padded = (frac + '0'.repeat(decimals)).slice(0, decimals) return BigInt(whole + padded) } -const toHex = (value: bigint | number): string => { +export const toHex = (value: bigint | number): string => { let hex = BigInt(value).toString(16) if (hex.length % 2) hex = '0' + hex return '0x' + hex } +/** Encode ERC-20 approve(spender, amount) call data */ +export function encodeApprove(spender: string, amount: bigint): string { + const selector = '095ea7b3' // approve(address,uint256) + const spenderPad = spender.toLowerCase().replace(/^0x/, '').padStart(64, '0') + const amountPad = amount.toString(16).padStart(64, '0') + return '0x' + selector + spenderPad + amountPad +} + /** Encode ERC-20 transfer(address,uint256) call data */ function encodeTransferData(toAddress: string, amountBaseUnits: bigint): string { const selector = 'a9059cbb' // transfer(address,uint256) @@ -31,6 +39,39 @@ function encodeTransferData(toAddress: string, amountBaseUnits: bigint): string return '0x' + selector + addrPadded + amtPadded } +/** + * Encode THORChain router depositWithExpiry(address,address,uint256,string,uint256) + * Selector: 0x44bc937b + * + * For native ETH swaps: asset = 0x0...0, value = amount + * For ERC-20 swaps: asset = token contract, value = 0 (requires prior approval) + */ +export function encodeDepositWithExpiry( + vault: string, + asset: string, // 0x0...0 for native, token address for ERC-20 + amount: bigint, + memo: string, + expiry: number, +): string { + const selector = '44bc937b' + const vaultPad = vault.toLowerCase().replace(/^0x/, '').padStart(64, '0') + const assetPad = asset.toLowerCase().replace(/^0x/, '').padStart(64, '0') + const amountPad = amount.toString(16).padStart(64, '0') + // String offset: 5 head words × 32 bytes = 160 = 0xa0 + const stringOffset = (5 * 32).toString(16).padStart(64, '0') + const expiryPad = BigInt(expiry).toString(16).padStart(64, '0') + + // Encode memo string: length prefix + UTF-8 bytes padded to 32-byte boundary + // Empty memo is valid ABI — zero length + no data words + const memoBytes = Buffer.from(memo, 'utf8') + const memoLen = memoBytes.length.toString(16).padStart(64, '0') + const memoPadded = memoBytes.length === 0 + ? '' // zero-length string: only the length word (0x00...00) is needed + : memoBytes.toString('hex').padEnd(Math.ceil(memoBytes.length / 32) * 64, '0') + + return '0x' + selector + vaultPad + assetPad + amountPad + stringOffset + expiryPad + memoLen + memoPadded +} + /** Extract contract address from CAIP-19 like "eip155:1/erc20:0xdac17f..." */ function extractContractFromCaip(caip: string): string { const match = caip.match(/\/erc20:(0x[a-fA-F0-9]{40})/) diff --git a/projects/keepkey-vault/src/bun/txbuilder/index.ts b/projects/keepkey-vault/src/bun/txbuilder/index.ts index af99603..f513a85 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/index.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/index.ts @@ -63,6 +63,7 @@ export async function buildTx( amount: params.amount, memo: params.memo, isMax: params.isMax, + isSwapDeposit: params.isSwapDeposit, fromAddress: params.fromAddress, }) const { fee: cosmosFee, ...cosmosTx } = cosmosResult diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts index 6be5afc..c45513a 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts @@ -112,9 +112,15 @@ function getUtxoScriptPubKeyHex(utxo: any): string | undefined { // Derive scriptType from xpub prefix (Pioneer SDK pattern) function getScriptTypeFromXpub(xpub: string): string | undefined { + // BTC if (xpub.startsWith('zpub')) return 'p2wpkh' if (xpub.startsWith('ypub')) return 'p2sh-p2wpkh' if (xpub.startsWith('xpub')) return 'p2pkh' + // DOGE (dgub), BCH, DASH (drkp) — all legacy p2pkh + if (xpub.startsWith('dgub') || xpub.startsWith('drkp')) return 'p2pkh' + // LTC + if (xpub.startsWith('Mtub')) return 'p2wpkh' + if (xpub.startsWith('Ltub')) return 'p2sh-p2wpkh' return undefined // unknown prefix — let caller fall back } @@ -229,19 +235,34 @@ export async function buildUtxoTx( let { inputs, outputs, fee } = result - // DOGE: enforce minimum 1 DOGE fee + // DOGE: enforce minimum 1 DOGE fee (network consensus rule) + // Pioneer SDK: DOGE_MIN_FEE = 100000000 (1 DOGE), dust = 1000000 (0.01 DOGE) if (chain.id === 'dogecoin' && fee < 100000000) { const increase = 100000000 - fee const changeIdx = outputs.findIndex((o: any) => !o.address) if (changeIdx >= 0 && outputs[changeIdx].value >= increase) { + // Absorb fee deficit from change output outputs[changeIdx].value -= increase if (outputs[changeIdx].value < 1000000) { + // Change is dust — consolidate into fee fee = 100000000 + outputs[changeIdx].value outputs.splice(changeIdx, 1) } else { fee = 100000000 } + } else { + // No change output (or change too small) — reduce spend output to cover fee. + // For swaps this is fine: THORChain swaps whatever it receives. + const spendIdx = outputs.findIndex((o: any) => o.address) + if (spendIdx >= 0 && outputs[spendIdx].value > increase + 1000000) { + console.log(`${TAG} DOGE: no change output — reducing spend by ${increase} sats to enforce 1 DOGE min fee`) + outputs[spendIdx].value -= increase + fee = 100000000 + } else { + throw new Error(`Insufficient DOGE to cover minimum 1 DOGE network fee`) + } } + console.log(`${TAG} DOGE: enforced minimum fee = ${fee} sats (${fee / 1e8} DOGE)`) } // 4. Get pubkey info — used for both address→path lookup AND change address index @@ -391,14 +412,12 @@ export async function buildUtxoTx( }) .filter(Boolean) - // OP_RETURN memo - if (memo && memo.trim()) { - preparedOutputs.push({ - amount: '0', - addressType: 'opreturn', - opReturnData: memo, - }) - } + // OP_RETURN memo — pass raw UTF-8 string as top-level opReturnData ONLY. + // hdwallet-keepkey btcSignTx() does its own base64 encoding (line 296): + // Buffer.from(msg.opReturnData).toString("base64") + // Do NOT add to preparedOutputs — hdwallet creates the output internally. + // Do NOT pre-encode (hex/base64) — causes double-encoding → garbled on-chain. + const memoRaw = memo && memo.trim() ? memo.trim() : undefined // Safety validation — prevent fee burn or empty transactions if (!preparedInputs.length) throw new Error('No inputs selected — cannot build transaction') @@ -433,7 +452,7 @@ export async function buildUtxoTx( } : {}), fee: String(fee / 10 ** chain.decimals), memo, - // opReturnData at top-level for v1 server contract - ...(memo && memo.trim() ? { opReturnData: memo } : {}), + // opReturnData at top-level — raw UTF-8 string (hdwallet base64-encodes internally) + ...(memoRaw ? { opReturnData: memoRaw } : {}), } } diff --git a/projects/keepkey-vault/src/bun/txbuilder/xrp.ts b/projects/keepkey-vault/src/bun/txbuilder/xrp.ts index 3ed21b4..cba9c06 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/xrp.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/xrp.ts @@ -37,15 +37,19 @@ export async function buildXrpTx( let memoData: string | undefined if (memo && memo.trim()) { - if (/^\d+$/.test(memo.trim())) { - const tagNum = parseInt(memo.trim(), 10) + const trimmed = memo.trim() + // THORChain/swap memos (e.g. "=:ETH.ETH:0x...") are always memo data, never a destination tag. + // Only treat as destination tag if purely numeric AND not a swap routing memo. + const isSwapMemo = /^[=+\-~]?:/.test(trimmed) + if (!isSwapMemo && /^\d+$/.test(trimmed)) { + const tagNum = parseInt(trimmed, 10) if (tagNum >= 0 && tagNum <= 4294967295) { destinationTag = String(tagNum) } else { throw new Error(`XRP destination tag must be 0-4294967295, got: ${memo}`) } } else { - memoData = memo.trim() + memoData = trimmed } } diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index a756b01..f6b6846 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -25,6 +25,7 @@ import { useDeviceState } from "./hooks/useDeviceState" import { useUpdateState } from "./hooks/useUpdateState" import { rpcRequest, onRpcMessage } from "./lib/rpc" import { Z } from "./lib/z-index" +import { SwapTracker } from "./components/SwapTracker" import type { PinRequestType, PairingRequestInfo, SigningRequestInfo, ApiLogEntry, AppSettings } from "../shared/types" type AppPhase = "splash" | "claimed" | "setup" | "ready" @@ -41,6 +42,7 @@ function App() { const [updateDismissed, setUpdateDismissed] = useState(false) const [appVersion, setAppVersion] = useState<{ version: string; channel: string } | null>(null) const [restApiEnabled, setRestApiEnabled] = useState(false) + const [swapsEnabled, setSwapsEnabled] = useState(false) const [pendingAppUrl, setPendingAppUrl] = useState(null) const [pendingWcOpen, setPendingWcOpen] = useState(false) const [enablingApi, setEnablingApi] = useState(false) @@ -62,7 +64,7 @@ function App() { .then(setAppVersion) .catch(() => {}) rpcRequest("getAppSettings") - .then((s) => setRestApiEnabled(s.restApiEnabled)) + .then((s) => { setRestApiEnabled(s.restApiEnabled); setSwapsEnabled(s.swapsEnabled) }) .catch(() => {}) }, []) @@ -589,7 +591,12 @@ function App() { setSettingsOpen(false)} + onClose={() => { + setSettingsOpen(false) + rpcRequest("getAppSettings") + .then((s) => { setRestApiEnabled(s.restApiEnabled); setSwapsEnabled(s.swapsEnabled) }) + .catch(() => {}) + }} deviceState={deviceState} onCheckForUpdate={update.checkForUpdate} updatePhase={update.phase} @@ -614,6 +621,7 @@ function App() { wcUri={wcUri} onClose={handleCloseWalletConnect} /> + {swapsEnabled && } {/* Enable API Bridge dialog — shown when user tries to launch an app with REST disabled */} {(pendingAppUrl || pendingWcOpen) && ( <> diff --git a/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx b/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx index b363a50..c0db8a8 100644 --- a/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx +++ b/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx @@ -1,5 +1,7 @@ import CountUp from "react-countup" import { Text, type TextProps } from "@chakra-ui/react" +import { useFiat } from "../lib/fiat-context" +import { getFiatConfig } from "../../shared/fiat" interface AnimatedUsdProps extends TextProps { value: number @@ -8,15 +10,24 @@ interface AnimatedUsdProps extends TextProps { duration?: number } -/** Animated USD counter — drops in anywhere a static $X.XX was shown. */ -export function AnimatedUsd({ value, prefix = "$", suffix, duration = 1.2, ...textProps }: AnimatedUsdProps) { +/** Animated fiat counter — uses the user's chosen currency and locale. */ +export function AnimatedUsd({ value, prefix, suffix, duration = 1.2, ...textProps }: AnimatedUsdProps) { + const { currency, locale } = useFiat() + const cfg = getFiatConfig(currency) + const displayPrefix = prefix !== undefined ? prefix : cfg.symbol + + // Determine separator from locale + const parts = new Intl.NumberFormat(locale).formatToParts(1234.5) + const group = parts.find(p => p.type === 'group')?.value || ',' + const decimal = parts.find(p => p.type === 'decimal')?.value || '.' + if (!isFinite(value) || value <= 0) { - return {prefix}0.00{suffix} + return {displayPrefix}0{decimal}{'0'.repeat(cfg.decimals)}{suffix} } return ( - {prefix} - + {displayPrefix} + {suffix} ) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 5ce35cc..5ee83fc 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -1,16 +1,17 @@ import { useState, useEffect, useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" import { Box, Flex, Text, Button, Image, VStack, HStack, IconButton } from "@chakra-ui/react" -import { FaArrowDown, FaArrowUp, FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck } from "react-icons/fa" +import { FaArrowDown, FaArrowUp, FaExchangeAlt, FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck } from "react-icons/fa" import { rpcRequest } from "../lib/rpc" import type { ChainDef } from "../../shared/chains" import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported } from "../../shared/chains" -import type { ChainBalance, TokenBalance, TokenVisibilityStatus } from "../../shared/types" +import type { ChainBalance, TokenBalance, TokenVisibilityStatus, AppSettings } from "../../shared/types" import { getAssetIcon, caipToIcon } from "../../shared/assetLookup" import { AnimatedUsd } from "./AnimatedUsd" import { formatBalance, formatUsd } from "../lib/formatting" import { ReceiveView } from "./ReceiveView" import { SendForm } from "./SendForm" +import { SwapDialog } from "./SwapDialog" import { ZcashPrivacyTab } from "./ZcashPrivacyTab" import { BtcXpubSelector } from "./BtcXpubSelector" import { EvmAddressSelector } from "./EvmAddressSelector" @@ -37,6 +38,14 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage const [deriveError, setDeriveError] = useState(null) const [currentPath, setCurrentPath] = useState(chain.defaultPath) + // Feature flag: swaps + const [swapsEnabled, setSwapsEnabled] = useState(false) + useEffect(() => { + rpcRequest("getAppSettings") + .then(s => setSwapsEnabled(s.swapsEnabled)) + .catch(() => {}) + }, []) + // BTC multi-account support const isBtc = chain.id === 'bitcoin' const { btcAccounts, selectXpub, addAccount, loading: btcLoading } = useBtcAccounts() @@ -196,6 +205,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage const cleanBalanceUsd = (balance?.balanceUsd || 0) - spamTotalUsd const [showAddToken, setShowAddToken] = useState(false) + const [showSwapDialog, setShowSwapDialog] = useState(false) const isEvmChain = chain.chainFamily === 'evm' // Toggle token visibility via RPC @@ -225,9 +235,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage const zcashShieldedDef = CHAINS.find(c => c.id === 'zcash-shielded') const zcashShieldedSupported = isZcash && zcashShieldedDef && isChainSupported(zcashShieldedDef, firmwareVersion) - const PILLS: { id: AssetView; label: string; icon: typeof FaArrowDown }[] = [ + const PILLS: { id: AssetView | 'swap'; label: string; icon: typeof FaArrowDown }[] = [ { id: "receive", label: t("receive"), icon: FaArrowDown }, { id: "send", label: t("send"), icon: FaArrowUp }, + ...(swapsEnabled ? [{ id: "swap" as const, label: t("swap"), icon: FaExchangeAlt }] : []), ...(zcashShieldedSupported ? [{ id: "privacy" as const, label: t("privacy"), icon: FaShieldAlt }] : []), ] @@ -428,7 +439,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage px={{ base: "5", md: "6" }} py="2" borderRadius="md" - onClick={() => { setView(p.id); if (p.id === 'receive') setSelectedToken(null) }} + onClick={() => { + if (p.id === 'swap') { setShowSwapDialog(true); return } + setView(p.id as AssetView); if (p.id === 'receive') setSelectedToken(null) + }} display="flex" alignItems="center" gap="1.5" @@ -511,6 +525,14 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage evmAddressIndex={isEvm ? evmAddresses.selectedIndex : undefined} /> )} + {/* SwapDialog rendered as overlay */} + setShowSwapDialog(false)} + chain={chain} + balance={balance} + address={address} + /> {view === "privacy" && isZcash && ( )} diff --git a/projects/keepkey-vault/src/mainview/components/CurrencySelector.tsx b/projects/keepkey-vault/src/mainview/components/CurrencySelector.tsx new file mode 100644 index 0000000..3d3f259 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/CurrencySelector.tsx @@ -0,0 +1,91 @@ +import { Flex, Box, Text } from "@chakra-ui/react" +import { useFiat } from "../lib/fiat-context" +import { FIAT_CURRENCIES, getFiatConfig } from "../../shared/fiat" +import type { FiatCurrency } from "../../shared/types" + +const LOCALE_OPTIONS: { locale: string; label: string }[] = [ + { locale: 'en-US', label: '1,234.56 (US)' }, + { locale: 'de-DE', label: '1.234,56 (EU)' }, + { locale: 'fr-FR', label: '1\u202F234,56 (FR)' }, + { locale: 'en-IN', label: '1,23,456.78 (IN)' }, + { locale: 'ja-JP', label: '1,234 (JP)' }, + { locale: 'pt-BR', label: '1.234,56 (BR)' }, +] + +export function CurrencySelector() { + const { currency, locale, setCurrency, setLocale } = useFiat() + + return ( + + {/* Fiat currency grid */} + Currency + + {FIAT_CURRENCIES.map(({ code, symbol, name }) => { + const active = currency === code + return ( + { + setCurrency(code) + // Auto-set locale to currency's default locale + const cfg = getFiatConfig(code) + setLocale(cfg.locale) + }} + title={name} + > + {symbol} {code} + + ) + })} + + + {/* Number format */} + Number Format + + {LOCALE_OPTIONS.map(({ locale: loc, label }) => { + const active = locale === loc + return ( + setLocale(loc)} + > + {label} + + ) + })} + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 1ccd840..7bcd7b0 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -167,6 +167,16 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings, firmwareVersion setLoadingBalances(false) }, [loadingBalances, watchOnly]) + // Auto-refresh balances when a swap completes (both chains affected) + useEffect(() => { + const handler = () => { + console.log('[Dashboard] Swap completed — refreshing balances') + refreshBalances() + } + window.addEventListener('keepkey-swap-completed', handler) + return () => window.removeEventListener('keepkey-swap-completed', handler) + }, [refreshBalances]) + // Compute spam-filtered USD per chain: subtract spam token values from chain totals const cleanBalanceUsd = useMemo(() => { const overrides = new Map(Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v])) diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index be6cb42..ae42861 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react" import { Box, Flex, Text, VStack, Button, Input, IconButton } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { LanguageSelector } from "../i18n/LanguageSelector" +import { CurrencySelector } from "./CurrencySelector" import { rpcRequest } from "../lib/rpc" import { Z } from "../lib/z-index" import type { DeviceStateInfo, AppSettings } from "../../shared/types" @@ -144,8 +145,9 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const [removingPin, setRemovingPin] = useState(false) const [removePinConfirm, setRemovePinConfirm] = useState(false) const [togglingPassphrase, setTogglingPassphrase] = useState(false) - const [appSettings, setAppSettings] = useState({ restApiEnabled: false, pioneerApiBase: '' }) + const [appSettings, setAppSettings] = useState({ restApiEnabled: false, pioneerApiBase: '', fiatCurrency: 'USD', numberLocale: 'en-US', swapsEnabled: false }) const [togglingRestApi, setTogglingRestApi] = useState(false) + const [togglingSwaps, setTogglingSwaps] = useState(false) const [checkingUpdate, setCheckingUpdate] = useState(false) const [updateMessage, setUpdateMessage] = useState("") const [pioneerUrl, setPioneerUrl] = useState("") @@ -240,6 +242,15 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd setTogglingRestApi(false) }, [onRestApiChanged]) + const toggleSwaps = useCallback(async (enabled: boolean) => { + setTogglingSwaps(true) + try { + const result = await rpcRequest("setSwapsEnabled", { enabled }, 10000) + setAppSettings(result) + } catch (e: any) { console.error("setSwapsEnabled:", e) } + setTogglingSwaps(false) + }, []) + const openSwagger = useCallback(async () => { try { await rpcRequest("openUrl", { url: "http://localhost:1646/docs" }, 5000) @@ -393,9 +404,12 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd {/* Content */} - {/* ── Language ────────────────────────────────────── */} + {/* ── Language & Currency ─────────────────────────── */}
+ + +
{/* ── Device Identity ─────────────────────────────── */} @@ -812,6 +826,36 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd
+ {/* ── Feature Flags ──────────────────────────────── */} +
+ + {/* Swaps toggle */} + + + + + + + + + + + + {t("swapsFeature")} + + {t("swapsFeatureDescription")} + + + + + + +
+ {/* ── Developer ───────────────────────────────────── */}
diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx new file mode 100644 index 0000000..779ec3b --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -0,0 +1,1635 @@ +/** + * SwapDialog — Full-screen dialog for the swap flow. + * + * Phases: input → review → approving/signing/broadcasting → success + * Replaces the old inline SwapView with a proper modal experience. + */ +import { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { useTranslation } from "react-i18next" +import { Box, Flex, Text, VStack, Button, Input, Image, HStack } from "@chakra-ui/react" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { formatBalance } from "../lib/formatting" +import { useFiat } from "../lib/fiat-context" +import { getAssetIcon } from "../../shared/assetLookup" +import { CHAINS, getExplorerTxUrl } from "../../shared/chains" +import type { ChainDef } from "../../shared/chains" +import type { SwapAsset, SwapQuote, ChainBalance, SwapStatusUpdate, SwapTrackingStatus, PendingSwap } from "../../shared/types" +import { Z } from "../lib/z-index" + +// ── Phase state machine ───────────────────────────────────────────── +type SwapPhase = 'input' | 'quoting' | 'review' | 'approving' | 'signing' | 'broadcasting' | 'submitted' + +// ── Supported THORChain chains ────────────────────────────────────── +const SWAP_CHAIN_IDS = new Set([ + 'bitcoin', 'ethereum', 'litecoin', 'dogecoin', 'bitcoincash', + 'dash', 'cosmos', 'thorchain', 'mayachain', 'avalanche', + 'bsc', 'base', 'arbitrum', 'optimism', 'polygon', +]) + +const DEFAULT_OUTPUT: Record = { + bitcoin: 'ETH.ETH', + ethereum: 'BTC.BTC', + litecoin: 'BTC.BTC', + dogecoin: 'BTC.BTC', + bitcoincash: 'BTC.BTC', + dash: 'BTC.BTC', + cosmos: 'ETH.ETH', + thorchain: 'ETH.ETH', + mayachain: 'ETH.ETH', + avalanche: 'ETH.ETH', + bsc: 'ETH.ETH', + base: 'ETH.ETH', + arbitrum: 'ETH.ETH', + optimism: 'ETH.ETH', + polygon: 'ETH.ETH', +} + +// ── Icons ─────────────────────────────────────────────────────────── +const SwapArrowIcon = () => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + +const SearchIcon = () => ( + + + + +) + +const ThorchainIcon = ({ size = 14 }: { size?: number }) => ( + + + + +) + +const CheckIcon = () => ( + + + +) + +const ShieldIcon = () => ( + + + +) + +const SwapInputIcon = () => ( + + + + +) + +// ── External link icon ────────────────────────────────────────────── +const ExternalLinkIcon = () => ( + + + + + +) + +// ── Confetti burst (CSS-only, 30 particles) ───────────────────────── +function ConfettiBurst() { + const colors = ['#4ADE80', '#23DCC8', '#FFD700', '#FF6B6B', '#A78BFA', '#3B82F6', '#FB923C', '#F472B6'] + const particles = Array.from({ length: 30 }, (_, i) => { + const angle = (i / 30) * 360 + const dist = 80 + Math.random() * 100 + const x = Math.cos(angle * Math.PI / 180) * dist + const y = Math.sin(angle * Math.PI / 180) * dist - 40 + const color = colors[i % colors.length] + const size = 4 + Math.random() * 5 + const delay = Math.random() * 0.2 + const rotation = Math.random() * 720 + return { x, y, color, size, delay, rotation, id: i } + }) + return ( + + {particles.map(p => ( + + ))} + + ) +} + +// ── Play completion chime via Web Audio API ───────────────────────── +function playCompletionSound() { + try { + const ctx = new AudioContext() + const now = ctx.currentTime + // Play a pleasant two-note chime (G5 → C6) + const notes = [784, 1047] + notes.forEach((freq, i) => { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = 'sine' + osc.frequency.value = freq + gain.gain.setValueAtTime(0, now + i * 0.15) + gain.gain.linearRampToValueAtTime(0.15, now + i * 0.15 + 0.03) + gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.15 + 0.5) + osc.connect(gain) + gain.connect(ctx.destination) + osc.start(now + i * 0.15) + osc.stop(now + i * 0.15 + 0.6) + }) + setTimeout(() => ctx.close(), 1500) + } catch { /* audio not available */ } +} + +const DIALOG_CSS = ` + @keyframes kkSwapPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(35,220,200,0.5); } + 50% { box-shadow: 0 0 0 8px rgba(35,220,200,0); } + } + @keyframes kkSwapCheckPop { + 0% { transform: scale(0); opacity: 0; } + 60% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } + } + @keyframes kkSwapDevicePulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255,215,0,0.4); transform: scale(1); } + 50% { box-shadow: 0 0 20px 8px rgba(255,215,0,0.15); transform: scale(1.02); } + } + @keyframes kkSwapFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes kkConfetti { + 0% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; } + 100% { transform: translate(var(--cx), var(--cy)) rotate(var(--cr)) scale(0.3); opacity: 0; } + } +` + +// ── Asset Selector ────────────────────────────────────────────────── +interface AssetSelectorProps { + label: string + selected: SwapAsset | null + assets: SwapAsset[] + onSelect: (asset: SwapAsset) => void + balances?: ChainBalance[] + exclude?: string + disabled?: boolean + nativeOnly?: boolean +} + +function AssetSelector({ label, selected, assets, onSelect, balances, exclude, disabled, nativeOnly }: AssetSelectorProps) { + const { t } = useTranslation("swap") + const { fmtCompact } = useFiat() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState("") + const inputRef = useRef(null) + + useEffect(() => { + if (open && inputRef.current) inputRef.current.focus() + }, [open]) + + const filtered = useMemo(() => { + let list = exclude ? assets.filter(a => a.asset !== exclude) : assets + if (nativeOnly) list = list.filter(a => !a.contractAddress) + if (search) { + const q = search.toLowerCase() + list = list.filter(a => + a.symbol.toLowerCase().includes(q) || + a.name.toLowerCase().includes(q) || + a.chainId.toLowerCase().includes(q) + ) + } + return list.slice(0, 50) + }, [assets, search, exclude, nativeOnly]) + + const getBalance = useCallback((asset: SwapAsset): { balance: string; usd: number } | null => { + if (!balances) return null + const chain = balances.find(b => b.chainId === asset.chainId) + if (!chain) return null + if (asset.contractAddress && chain.tokens) { + const token = chain.tokens.find(t => + t.contractAddress?.toLowerCase() === asset.contractAddress?.toLowerCase() + ) + return token ? { balance: token.balance, usd: token.balanceUsd || 0 } : null + } + return { balance: chain.balance, usd: chain.balanceUsd || 0 } + }, [balances]) + + const chainIcon = useCallback((asset: SwapAsset) => { + const chainDef = CHAINS.find(c => c.id === asset.chainId) + if (chainDef?.caip) return getAssetIcon(chainDef.caip) + return `https://pioneers.dev/coins/${asset.symbol.toLowerCase()}.png` + }, []) + + if (open) { + return ( + + {label} + + + + setSearch(e.target.value)} + placeholder={t("searchAssets")} + bg="transparent" + border="none" + color="kk.textPrimary" + size="sm" + px="0" + _focus={{ outline: "none", boxShadow: "none" }} + /> + + + + {filtered.length === 0 ? ( + {t("noAssets")} + ) : ( + filtered.map((asset) => { + const balInfo = getBalance(asset) + return ( + { onSelect(asset); setOpen(false); setSearch("") }} + > + {asset.symbol} { (e.target as HTMLImageElement).style.display = 'none' }} + /> + + {asset.symbol} + {asset.name} + + {balInfo && ( + + {formatBalance(balInfo.balance)} + {balInfo.usd > 0 && ( + {fmtCompact(balInfo.usd)} + )} + + )} + + ) + }) + )} + + + + ) + } + + return ( + + {label} + { if (!disabled) setOpen(true) }} + > + {selected ? ( + <> + {selected.symbol} { (e.target as HTMLImageElement).style.display = 'none' }} + /> + + {selected.symbol} + {selected.name} + + + ) : ( + {t("selectAsset")} + )} + {!disabled && } + + + ) +} + +// ── Props ─────────────────────────────────────────────────────────── +interface SwapDialogProps { + open: boolean + onClose: () => void + chain?: ChainDef + balance?: ChainBalance + address?: string | null + resumeSwap?: PendingSwap | null +} + +// ── Main SwapDialog ───────────────────────────────────────────────── +export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap }: SwapDialogProps) { + const { t } = useTranslation("swap") + const { fmtCompact, symbol: fiatSymbol } = useFiat() + + // ── State ───────────────────────────────────────────────────────── + const [phase, setPhase] = useState('input') + const [assets, setAssets] = useState([]) + const [loadingAssets, setLoadingAssets] = useState(true) + const [balances, setBalances] = useState([]) + + const [fromAsset, setFromAsset] = useState(null) + const [toAsset, setToAsset] = useState(null) + const [amount, setAmount] = useState("") + const [fiatAmount, setFiatAmount] = useState("") + const [inputMode, setInputMode] = useState<'crypto' | 'fiat'>('crypto') + const [isMax, setIsMax] = useState(false) + + const [quote, setQuote] = useState(null) + const [error, setError] = useState(null) + const [txid, setTxid] = useState(null) + const [copied, setCopied] = useState(false) + + // ── Live swap tracking state ──────────────────────────────────── + const [liveStatus, setLiveStatus] = useState('pending') + const [liveConfirmations, setLiveConfirmations] = useState(0) + const [liveOutboundConfirmations, setLiveOutboundConfirmations] = useState() + const [liveOutboundRequired, setLiveOutboundRequired] = useState() + const [liveOutboundTxid, setLiveOutboundTxid] = useState() + + // ── Before/after balance tracking ───────────────────────────────── + const [beforeFromBal, setBeforeFromBal] = useState(null) + const [beforeToBal, setBeforeToBal] = useState(null) + const [afterFromBal, setAfterFromBal] = useState(null) + const [afterToBal, setAfterToBal] = useState(null) + const [showConfetti, setShowConfetti] = useState(false) + const completionFiredRef = useRef(false) + + // ── Derived terminal status (must be before effects that depend on them) ── + const isSwapComplete = liveStatus === 'completed' + const isSwapFailed = liveStatus === 'failed' || liveStatus === 'refunded' + + // ── Listen for swap-update + swap-complete RPC messages ───────── + useEffect(() => { + if (!txid || phase !== 'submitted') return + + const unsub1 = onRpcMessage('swap-update', (update: SwapStatusUpdate) => { + if (update.txid !== txid) return + setLiveStatus(update.status) + if (update.confirmations !== undefined) setLiveConfirmations(update.confirmations) + if (update.outboundConfirmations !== undefined) setLiveOutboundConfirmations(update.outboundConfirmations) + if (update.outboundRequiredConfirmations !== undefined) setLiveOutboundRequired(update.outboundRequiredConfirmations) + if (update.outboundTxid) setLiveOutboundTxid(update.outboundTxid) + }) + + const unsub2 = onRpcMessage('swap-complete', (swap: any) => { + if (swap.txid !== txid) return + setLiveStatus(swap.status || 'completed') + }) + + return () => { unsub1(); unsub2() } + }, [txid, phase]) + + // Reset live tracking when phase changes away from submitted + useEffect(() => { + if (phase !== 'submitted') { + setLiveStatus('pending') + setLiveConfirmations(0) + setLiveOutboundConfirmations(undefined) + setLiveOutboundRequired(undefined) + setLiveOutboundTxid(undefined) + setAfterFromBal(null) + setAfterToBal(null) + setShowConfetti(false) + completionFiredRef.current = false + } + }, [phase]) + + // Fire confetti + sound + fetch after-balances when swap completes + useEffect(() => { + if (!isSwapComplete || completionFiredRef.current) return + completionFiredRef.current = true + setShowConfetti(true) + playCompletionSound() + setTimeout(() => setShowConfetti(false), 1500) + // Fetch updated balances to show before/after diff + rpcRequest('getBalances', undefined, 60000) + .then((result) => { + if (!result || !fromAsset || !toAsset) return + const fromCb = result.find(b => b.chainId === fromAsset.chainId) + const toCb = result.find(b => b.chainId === toAsset.chainId) + if (fromCb) { + if (fromAsset.contractAddress && fromCb.tokens) { + const tok = fromCb.tokens.find(t => t.contractAddress?.toLowerCase() === fromAsset.contractAddress?.toLowerCase()) + setAfterFromBal(tok?.balance || '0') + } else { + setAfterFromBal(fromCb.balance) + } + } + if (toCb) { + if (toAsset.contractAddress && toCb.tokens) { + const tok = toCb.tokens.find(t => t.contractAddress?.toLowerCase() === toAsset.contractAddress?.toLowerCase()) + setAfterToBal(tok?.balance || '0') + } else { + setAfterToBal(toCb.balance) + } + } + }) + .catch(() => {}) + }, [isSwapComplete, fromAsset, toAsset]) + + // ── Derived: which step are we on? ────────────────────────────── + // Step 0: Input (pending/confirming) — inbound tx being confirmed + // Step 1: Protocol (confirming with enough confs) — THORChain processing + // Step 2: Output (output_detected/output_confirming) — outbound tx + // Step 3: Done (completed) + const swapStep = useMemo(() => { + if (liveStatus === 'completed') return 3 + if (liveStatus === 'output_detected' || liveStatus === 'output_confirming' || liveStatus === 'output_confirmed') return 2 + if (liveStatus === 'confirming') return 1 + return 0 // pending + }, [liveStatus]) + + // ── Load cached balances ────────────────────────────────────────── + useEffect(() => { + if (!open) return + rpcRequest<{ balances: ChainBalance[]; updatedAt: number } | null>('getCachedBalances', undefined, 5000) + .then((result) => { + if (result?.balances) setBalances(result.balances) + }) + .catch(() => {}) + }, [open]) + + // ── Load swap assets ────────────────────────────────────────────── + useEffect(() => { + if (!open) return + let cancelled = false + setLoadingAssets(true) + rpcRequest('getSwapAssets', undefined, 20000) + .then((result) => { + if (!cancelled) { + setAssets(result) + setLoadingAssets(false) + } + }) + .catch((e) => { + if (!cancelled) { + console.error('[SwapDialog] Failed to load assets:', e) + setLoadingAssets(false) + } + }) + return () => { cancelled = true } + }, [open]) + + // ── Auto-select from asset when dialog opens with chain context ─── + const hasAutoSelected = useRef(false) + useEffect(() => { + if (hasAutoSelected.current || assets.length === 0 || !chain) return + const match = assets.find(a => a.chainId === chain.id && !a.contractAddress) + if (match) { + setFromAsset(match) + const defaultOut = DEFAULT_OUTPUT[chain.id] + if (defaultOut) { + const outMatch = assets.find(a => a.asset === defaultOut) + if (outMatch) setToAsset(outMatch) + } + hasAutoSelected.current = true + } + }, [assets, chain]) + + // ── Resume from swap history ────────────────────────────────────── + const hasResumedRef = useRef(null) + useEffect(() => { + if (!open || !resumeSwap || hasResumedRef.current === resumeSwap.txid) return + hasResumedRef.current = resumeSwap.txid + + // Build minimal SwapAsset objects from PendingSwap data + const from: SwapAsset = { + asset: resumeSwap.fromAsset, + chainId: resumeSwap.fromChainId, + symbol: resumeSwap.fromSymbol, + name: resumeSwap.fromSymbol, + chainFamily: 'utxo', // not critical for submitted phase display + decimals: 8, + } + const to: SwapAsset = { + asset: resumeSwap.toAsset, + chainId: resumeSwap.toChainId, + symbol: resumeSwap.toSymbol, + name: resumeSwap.toSymbol, + chainFamily: 'utxo', + decimals: 8, + } + + setFromAsset(from) + setToAsset(to) + setAmount(resumeSwap.fromAmount) + setTxid(resumeSwap.txid) + setLiveStatus(resumeSwap.status) + setLiveConfirmations(resumeSwap.confirmations) + if (resumeSwap.outboundConfirmations !== undefined) setLiveOutboundConfirmations(resumeSwap.outboundConfirmations) + if (resumeSwap.outboundRequiredConfirmations !== undefined) setLiveOutboundRequired(resumeSwap.outboundRequiredConfirmations) + if (resumeSwap.outboundTxid) setLiveOutboundTxid(resumeSwap.outboundTxid) + // If resuming a terminal swap, suppress confetti/sound + const isTerminal = resumeSwap.status === 'completed' || resumeSwap.status === 'failed' || resumeSwap.status === 'refunded' + if (isTerminal) completionFiredRef.current = true + + setQuote({ + expectedOutput: resumeSwap.expectedOutput, + minimumOutput: resumeSwap.expectedOutput, + inboundAddress: resumeSwap.inboundAddress, + router: resumeSwap.router, + memo: resumeSwap.memo, + fees: { affiliate: '0', outbound: '0', totalBps: 0 }, + estimatedTime: resumeSwap.estimatedTime, + integration: resumeSwap.integration, + slippageBps: 0, + fromAsset: resumeSwap.fromAsset, + toAsset: resumeSwap.toAsset, + }) + setPhase('submitted') + }, [open, resumeSwap]) + + // ── Derived values ──────────────────────────────────────────────── + const fromBalance = useMemo(() => { + if (!fromAsset) return null + if (balance && chain && fromAsset.chainId === chain.id && !fromAsset.contractAddress) { + return balance.balance + } + const cb = balances.find(b => b.chainId === fromAsset.chainId) + if (!cb) return null + if (fromAsset.contractAddress && cb.tokens) { + const token = cb.tokens.find(t => + t.contractAddress?.toLowerCase() === fromAsset.contractAddress?.toLowerCase() + ) + return token ? token.balance : null + } + return cb.balance + }, [fromAsset, balance, chain, balances]) + + // Derive per-unit USD price for from/to assets from cached balances + const fromPriceUsd = useMemo(() => { + if (!fromAsset) return 0 + const cb = balance && chain && fromAsset.chainId === chain.id ? balance : balances.find(b => b.chainId === fromAsset.chainId) + if (!cb) return 0 + if (fromAsset.contractAddress && cb.tokens) { + const tok = cb.tokens.find(t => t.contractAddress?.toLowerCase() === fromAsset.contractAddress?.toLowerCase()) + return tok?.priceUsd || 0 + } + const bal = parseFloat(cb.balance) + return bal > 0 ? (cb.balanceUsd || 0) / bal : 0 + }, [fromAsset, balance, chain, balances]) + + const toPriceUsd = useMemo(() => { + if (!toAsset) return 0 + const cb = balances.find(b => b.chainId === toAsset.chainId) + if (!cb) return 0 + if (toAsset.contractAddress && cb.tokens) { + const tok = cb.tokens.find(t => t.contractAddress?.toLowerCase() === toAsset.contractAddress?.toLowerCase()) + return tok?.priceUsd || 0 + } + const bal = parseFloat(cb.balance) + return bal > 0 ? (cb.balanceUsd || 0) / bal : 0 + }, [toAsset, balances]) + + const hasFromPrice = fromPriceUsd > 0 + const hasToPrice = toPriceUsd > 0 + + // Bidirectional conversion: crypto → fiat + const handleCryptoChange = useCallback((v: string) => { + setAmount(v) + setIsMax(false) + if (hasFromPrice && v) { + const n = parseFloat(v) + if (!isNaN(n)) setFiatAmount((n * fromPriceUsd).toFixed(2)) + else setFiatAmount("") + } else { + setFiatAmount("") + } + }, [hasFromPrice, fromPriceUsd]) + + // Bidirectional conversion: fiat → crypto + const handleFiatChange = useCallback((v: string) => { + setFiatAmount(v) + setIsMax(false) + if (hasFromPrice && v) { + const n = parseFloat(v) + if (!isNaN(n)) { + const crypto = n / fromPriceUsd + setAmount(crypto < 1 ? crypto.toPrecision(8) : crypto.toFixed(8).replace(/\.?0+$/, '')) + } else { + setAmount("") + } + } else { + setAmount("") + } + }, [hasFromPrice, fromPriceUsd]) + + const toggleInputMode = useCallback(() => { + setInputMode(prev => prev === 'crypto' ? 'fiat' : 'crypto') + }, []) + + // USD preview of the entered amount + const amountUsdPreview = useMemo(() => { + if (!hasFromPrice || isMax) return null + const n = parseFloat(amount) + if (isNaN(n) || n <= 0) return null + return n * fromPriceUsd + }, [amount, hasFromPrice, fromPriceUsd, isMax]) + + const amountNum = parseFloat(amount) + const balanceNum = fromBalance ? parseFloat(fromBalance) : 0 + const exceedsBalance = !isMax && !isNaN(amountNum) && amountNum > 0 && balanceNum > 0 && amountNum > balanceNum + const sameAsset = fromAsset && toAsset && fromAsset.asset === toAsset.asset + + const fromAddress = useMemo(() => { + if (fromAsset && address && chain && fromAsset.chainId === chain.id) return address + if (!fromAsset) return '' + const cb = balances.find(b => b.chainId === fromAsset.chainId) + return cb?.address || '' + }, [fromAsset, address, chain, balances]) + + const toAddress = useMemo(() => { + if (!toAsset) return '' + const cb = balances.find(b => b.chainId === toAsset.chainId) + return cb?.address || '' + }, [toAsset, balances]) + + const validAmount = isMax || (amount !== '' && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0) + const canQuote = fromAsset && toAsset && !sameAsset && validAmount && fromAddress && toAddress && !exceedsBalance + + // ── Quote fetching ──────────────────────────────────────────────── + const quoteTimerRef = useRef | null>(null) + const quoteVersionRef = useRef(0) + + useEffect(() => { + if (quoteTimerRef.current) clearTimeout(quoteTimerRef.current) + setQuote(null) + const version = ++quoteVersionRef.current + + if (!canQuote) { + if (phase === 'quoting') setPhase('input') + return + } + + setPhase('quoting') + setError(null) + + quoteTimerRef.current = setTimeout(async () => { + try { + const result = await rpcRequest('getSwapQuote', { + fromAsset: fromAsset!.asset, + toAsset: toAsset!.asset, + amount: isMax ? (fromBalance || '0') : amount, + fromAddress, + toAddress, + slippageBps: 300, + }, 30000) + if (version !== quoteVersionRef.current) return + setQuote(result) + setPhase('review') + } catch (e: any) { + if (version !== quoteVersionRef.current) return + setError(e.message || t("errorQuote")) + setPhase('input') + } + }, 800) + + return () => { + if (quoteTimerRef.current) clearTimeout(quoteTimerRef.current) + } + }, [fromAsset?.asset, toAsset?.asset, amount, isMax, fromAddress, toAddress, exceedsBalance, fromBalance]) + + // ── Flip ────────────────────────────────────────────────────────── + const handleFlip = useCallback(() => { + const prev = fromAsset + setFromAsset(toAsset) + setToAsset(prev) + setAmount("") + setFiatAmount("") + setIsMax(false) + setQuote(null) + setPhase('input') + setError(null) + }, [fromAsset, toAsset]) + + // ── Execute swap ────────────────────────────────────────────────── + const handleExecuteSwap = useCallback(async () => { + if (!quote || !fromAsset || !toAsset) return + const isErc20 = !!fromAsset.contractAddress + setPhase(isErc20 ? 'approving' : 'signing') + setError(null) + + // Capture before-balances + const fromBal = fromBalance || '0' + setBeforeFromBal(fromBal) + const toCb = balances.find(b => b.chainId === toAsset.chainId) + if (toCb) { + if (toAsset.contractAddress && toCb.tokens) { + const tok = toCb.tokens.find(t => t.contractAddress?.toLowerCase() === toAsset.contractAddress?.toLowerCase()) + setBeforeToBal(tok?.balance || '0') + } else { + setBeforeToBal(toCb.balance) + } + } else { + setBeforeToBal('0') + } + + try { + const result = await rpcRequest<{ txid: string; approvalTxid?: string }>('executeSwap', { + fromChainId: fromAsset.chainId, + toChainId: toAsset.chainId, + fromAsset: fromAsset.asset, + toAsset: toAsset.asset, + amount: isMax ? (fromBalance || '0') : amount, + memo: quote.memo, + inboundAddress: quote.inboundAddress, + router: quote.router, + expiry: quote.expiry, + expectedOutput: quote.expectedOutput, + isMax, + feeLevel: 5, + }, 180000) + + setTxid(result.txid) + setPhase('submitted') + window.dispatchEvent(new CustomEvent('keepkey-swap-executed')) + } catch (e: any) { + setError(e.message || t("errorSwap")) + setPhase('review') + } + }, [quote, fromAsset, toAsset, amount, isMax, fromBalance, balances]) + + // ── Reset ───────────────────────────────────────────────────────── + const reset = useCallback(() => { + setPhase('input') + setFromAsset(null) + setToAsset(null) + setAmount("") + setFiatAmount("") + setInputMode('crypto') + setIsMax(false) + setQuote(null) + setError(null) + setTxid(null) + setBeforeFromBal(null) + setBeforeToBal(null) + setAfterFromBal(null) + setAfterToBal(null) + setShowConfetti(false) + completionFiredRef.current = false + hasAutoSelected.current = false + hasResumedRef.current = null + }, []) + + const handleClose = useCallback(() => { + if (phase === 'signing' || phase === 'broadcasting' || phase === 'approving') return + onClose() + // Reset state after close animation + setTimeout(reset, 200) + }, [phase, onClose, reset]) + + const copyTxid = useCallback(() => { + if (!txid) return + navigator.clipboard.writeText(txid) + .then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000) }) + .catch(() => {}) + }, [txid]) + + const formatTime = useCallback((seconds: number) => { + if (seconds < 60) return `~${seconds}${t("seconds")}` + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return secs > 0 ? `~${mins}${t("minutes")} ${secs}${t("seconds")}` : `~${mins}${t("minutes")}` + }, [t]) + + const busy = phase === 'approving' || phase === 'signing' || phase === 'broadcasting' + const displayAmount = isMax ? (fromBalance || '0') : amount + + const chainIcon = useCallback((asset: SwapAsset) => { + const chainDef = CHAINS.find(c => c.id === asset.chainId) + if (chainDef?.caip) return getAssetIcon(chainDef.caip) + return `https://pioneers.dev/coins/${asset.symbol.toLowerCase()}.png` + }, []) + + if (!open) return null + + // ── Not swappable ───────────────────────────────────────────────── + if (chain && !resumeSwap && !SWAP_CHAIN_IDS.has(chain.id)) { + return ( + + + e.stopPropagation()} textAlign="center"> + + {t("notSupported", { coin: chain.coin })} + + + + ) + } + + return ( + + + + e.stopPropagation()} + style={{ animation: 'kkSwapFadeIn 0.2s ease-out' }} + > + {/* ── Header ──────────────────────────────────────────────── */} + + + + + {phase === 'review' ? t("review") : phase === 'submitted' ? t("swapSubmitted") : t("title")} + + + {!busy && ( + + )} + + + {/* ── Body ────────────────────────────────────────────────── */} + + {/* Loading state */} + {loadingAssets && ( + + {t("loadingAssets")} + + )} + + {/* ── SUBMITTED — live tracking with step progress ──── */} + {phase === 'submitted' && txid && fromAsset && toAsset && ( + + {/* Confetti burst on completion */} + {showConfetti && } + + {/* Status icon + title inline */} + + {isSwapComplete ? ( + + + + ) : isSwapFailed ? ( + + + + ) : ( + + + + + + )} + + + {isSwapComplete ? t("swapCompleted") : isSwapFailed ? t("swapFailed") : t("swapSubmitted")} + + {!isSwapComplete && !isSwapFailed && ( + {t("waitingForConfirmations")} + )} + + + + {/* ── 3-Step Horizontal Progress ─────────────────────── */} + + + {/* Step 0: Input Transaction */} + + 0 ? "rgba(74,222,128,0.15)" : "rgba(35,220,200,0.15)"} + border="2px solid" borderColor={swapStep > 0 ? "#4ADE80" : swapStep === 0 ? "#23DCC8" : "kk.border"}> + {swapStep > 0 ? ( + + ) : ( + + )} + + = 0 ? "kk.textPrimary" : "kk.textMuted"} textAlign="center">{t("stageInput")} + {swapStep === 0 && liveConfirmations > 0 && ( + {liveConfirmations} {t("confirmations")} + )} + {swapStep > 0 && ( + {t("statusCompleted")} + )} + + + {/* Connector line 0→1 */} + 0 ? "#4ADE80" : "kk.border"} mt="14px" mx="-2" /> + + {/* Step 1: Protocol Processing */} + + 1 ? "rgba(74,222,128,0.15)" : swapStep === 1 ? "rgba(35,220,200,0.15)" : "rgba(255,255,255,0.04)"} + border="2px solid" borderColor={swapStep > 1 ? "#4ADE80" : swapStep === 1 ? "#23DCC8" : "kk.border"}> + {swapStep > 1 ? ( + + ) : swapStep === 1 ? ( + + ) : ( + + )} + + = 1 ? "kk.textPrimary" : "kk.textMuted"} textAlign="center">{t("stageProtocol")} + {swapStep === 1 && ( + {t("statusConfirming")}... + )} + {swapStep > 1 && ( + {t("statusCompleted")} + )} + + + {/* Connector line 1→2 */} + 1 ? "#4ADE80" : "kk.border"} mt="14px" mx="-2" /> + + {/* Step 2: Output Transaction */} + + 2 ? "rgba(74,222,128,0.15)" : swapStep === 2 ? "rgba(35,220,200,0.15)" : "rgba(255,255,255,0.04)"} + border="2px solid" borderColor={swapStep > 2 ? "#4ADE80" : swapStep === 2 ? "#23DCC8" : "kk.border"}> + {swapStep > 2 ? ( + + ) : swapStep === 2 ? ( + + ) : ( + + )} + + = 2 ? "kk.textPrimary" : "kk.textMuted"} textAlign="center">{t("stageOutput")} + {swapStep === 2 && liveOutboundConfirmations !== undefined && ( + + {liveOutboundConfirmations}{liveOutboundRequired ? `/${liveOutboundRequired}` : ''} {t("confirmations")} + + )} + {swapStep === 2 && liveOutboundConfirmations === undefined && ( + {t("statusOutputDetected")} + )} + {swapStep > 2 && ( + {t("statusCompleted")} + )} + + + + + {/* ETA — only show when not complete */} + {!isSwapComplete && !isSwapFailed && quote?.estimatedTime && quote.estimatedTime > 0 && ( + + + {t("estimatedTime")}: {formatTime(quote.estimatedTime)} + + + )} + + {/* Amount summary */} + + + + + {displayAmount} {fromAsset.symbol} + + {hasFromPrice && ( + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + )} + + + + + + ~{quote?.expectedOutput} {toAsset.symbol} + + {hasToPrice && quote?.expectedOutput && ( + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + )} + + + + {/* Input Txid */} + + + + {t("txid")} + + {txid.slice(0, 12)}...{txid.slice(-8)} + + + + + {(() => { + const url = getExplorerTxUrl(fromAsset.chainId, txid) + return url ? ( + + ) : null + })()} + + + + + {/* Outbound Txid — shown when THORChain sends the output */} + {liveOutboundTxid && ( + + + + {t("stageOutput")} + + {liveOutboundTxid.slice(0, 12)}...{liveOutboundTxid.slice(-8)} + + + + + {(() => { + const url = getExplorerTxUrl(toAsset.chainId, liveOutboundTxid) + return url ? ( + + ) : null + })()} + + + + )} + + {/* Before / After balance comparison — shown on completion */} + {isSwapComplete && (beforeFromBal || beforeToBal) && ( + + + Balance Changes + + + {/* From asset balance change */} + + + + {fromAsset.symbol} + + + + + {beforeFromBal ? formatBalance(beforeFromBal) : '-'} + + + + {afterFromBal ? formatBalance(afterFromBal) : '...'} + + {afterFromBal && beforeFromBal && ( + + ({formatBalance((parseFloat(afterFromBal) - parseFloat(beforeFromBal)).toFixed(8))}) + + )} + + {hasFromPrice && afterFromBal && beforeFromBal && ( + + {fmtCompact((parseFloat(afterFromBal) - parseFloat(beforeFromBal)) * fromPriceUsd)} + + )} + + + {/* To asset balance change */} + + + + {toAsset.symbol} + + + + + {beforeToBal ? formatBalance(beforeToBal) : '-'} + + + + {afterToBal ? formatBalance(afterToBal) : '...'} + + {afterToBal && beforeToBal && ( + + (+{formatBalance((parseFloat(afterToBal) - parseFloat(beforeToBal)).toFixed(8))}) + + )} + + {hasToPrice && afterToBal && beforeToBal && ( + + +{fmtCompact((parseFloat(afterToBal) - parseFloat(beforeToBal)) * toPriceUsd)} + + )} + + + + + )} + + {/* Actions */} + + + + + + )} + + {/* ── SIGNING / APPROVING / BROADCASTING ───────────────── */} + {busy && fromAsset && toAsset && ( + + {/* Device icon with label inline */} + + + + + + + + + + {phase === 'approving' ? t("approvingToken") : phase === 'signing' ? t("confirmOnDevice") : t("broadcasting")} + + + {phase === 'signing' ? t("confirmOnDeviceDesc") : phase === 'approving' ? t("approvalRequired") : t("broadcastingDesc")} + + + + + {/* Mini summary */} + + + {displayAmount} {fromAsset.symbol} + + ~{quote?.expectedOutput} {toAsset.symbol} + + {hasFromPrice && ( + + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + {hasToPrice && quote?.expectedOutput ? ` \u2192 ${fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)}` : ''} + + )} + + + )} + + {/* ── REVIEW ───────────────────────────────────────────── */} + {phase === 'review' && quote && fromAsset && toAsset && !busy && ( + + {/* You Send / You Receive */} + + {t("youSend")} + + + + {displayAmount} {fromAsset.symbol} + + {fromAsset.name} + {hasFromPrice && ( + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + )} + + + + + + + + + + + + + + + {t("youReceive")} + + + + ~{quote.expectedOutput} {toAsset.symbol} + + {toAsset.name} + {hasToPrice && ( + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + )} + + + + + + {/* Quote details */} + + + + {t("rate")} + + + 1 {fromAsset.symbol} = {formatBalance( + (parseFloat(quote.expectedOutput) / parseFloat(displayAmount || '1')).toString() + )} {toAsset.symbol} + + {hasFromPrice && ( + + 1 {fromAsset.symbol} = {fmtCompact(fromPriceUsd)} + + )} + + + + {t("minimumReceived")} + + + {formatBalance(quote.minimumOutput)} {toAsset.symbol} + + {hasToPrice && ( + + {fmtCompact(parseFloat(quote.minimumOutput) * toPriceUsd)} + + )} + + + + {t("networkFee")} + + + {formatBalance(quote.fees.outbound)} ({quote.fees.totalBps / 100}%) + + {hasToPrice && ( + + {fmtCompact(parseFloat(quote.fees.outbound) * toPriceUsd)} + + )} + + + + {t("slippage")} + + {(quote.slippageBps / 100).toFixed(2)}% + + + + {t("estimatedTime")} + {formatTime(quote.estimatedTime)} + + + {quote.router && fromAsset.chainFamily === 'evm' && ( + + {t("routerContract")} + + {quote.router.slice(0, 8)}...{quote.router.slice(-6)} + + + )} + + {t("vault")} + + {quote.inboundAddress.slice(0, 8)}...{quote.inboundAddress.slice(-6)} + + + + {quote.warning && ( + {quote.warning} + )} + + + + {/* Security badge */} + + + {t("verifyOnDevice")} + + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Actions */} + + + + + + )} + + {/* ── INPUT ────────────────────────────────────────────── */} + {!loadingAssets && (phase === 'input' || phase === 'quoting') && ( + + {/* FROM card */} + + { setFromAsset(a); setQuote(null); setPhase('input'); setError(null) }} + balances={balances} + exclude={toAsset?.asset} + disabled={busy} + /> + + {fromAsset && ( + + + {t("available")}: + + {fromBalance ? `${formatBalance(fromBalance)} ${fromAsset.symbol}` : '\u2014'} + + {fromBalance && hasFromPrice && ( + + ({fmtCompact(parseFloat(fromBalance) * fromPriceUsd)}) + + )} + + {fromAddress && ( + + {fromAddress.slice(0, 8)}...{fromAddress.slice(-6)} + + )} + + )} + + {fromAsset && ( + <> + {/* Amount input label with toggle */} + + + {inputMode === 'crypto' ? `${t("amount")} (${fromAsset.symbol})` : `${t("amount")} (${fiatSymbol})`} + + {hasFromPrice && ( + + + {inputMode === 'crypto' ? fiatSymbol : fromAsset.symbol} + + )} + + + inputMode === 'crypto' ? handleCryptoChange(e.target.value) : handleFiatChange(e.target.value)} + placeholder={inputMode === 'fiat' ? '0.00' : t("amountPlaceholder")} + bg="rgba(0,0,0,0.3)" + border="1px solid" + borderColor={exceedsBalance ? "kk.error" : "kk.border"} + color="kk.textPrimary" + size="sm" + fontFamily="mono" + fontSize="md" + disabled={isMax || busy} + px="3" + flex="1" + _focus={{ borderColor: exceedsBalance ? "kk.error" : "kk.gold" }} + /> + + + {/* Secondary display: converted value */} + {!isMax && hasFromPrice && ( + + {inputMode === 'crypto' && amountUsdPreview !== null ? ( + {fmtCompact(amountUsdPreview)} + ) : inputMode === 'fiat' && amount ? ( + {formatBalance(amount)} {fromAsset.symbol} + ) : null} + + )} + + )} + + {exceedsBalance && ( + {t("insufficientBalance")} + )} + + + {/* Flip button */} + + + + + + + {/* TO card */} + + { setToAsset(a); setQuote(null); setPhase('input'); setError(null) }} + balances={balances} + exclude={fromAsset?.asset} + disabled={busy} + /> + + {toAsset && quote && ( + + {t("expectedOutput")}: + + + {formatBalance(quote.expectedOutput)} {toAsset.symbol} + + {hasToPrice && ( + + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + + )} + + + )} + {toAsset && toAddress && ( + + + → {toAddress.slice(0, 8)}...{toAddress.slice(-6)} + + + )} + {sameAsset && ( + {t("sameAsset")} + )} + + + {/* Quote loading */} + {phase === 'quoting' && ( + + {t("gettingQuote")} + + )} + + {/* Hint */} + {phase === 'input' && fromAsset && toAsset && !sameAsset && !amount && !isMax && ( + {t("enterAmount")} + )} + + {/* Error */} + {error && ( + + {error} + + )} + + )} + + + {/* ── Footer ──────────────────────────────────────────────── */} + {!loadingAssets && phase !== 'submitted' && !busy && phase !== 'review' && ( + + + + + {quote?.integration && quote.integration !== 'thorchain' + ? `via ${quote.integration}` + : t("poweredBy")} + + + + )} + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx new file mode 100644 index 0000000..a5e05c3 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx @@ -0,0 +1,831 @@ +/** + * SwapHistoryDialog — Full dialog for viewing active + historical swaps. + * + * Shows live pending swaps at top, SQLite-persisted history below. + * Supports filtering by status/date/asset and PDF/CSV export. + */ +import { useState, useEffect, useCallback, useMemo } from "react" +import { useTranslation } from "react-i18next" +import { Box, Flex, Text, VStack, HStack, Button, Input } from "@chakra-ui/react" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { Z } from "../lib/z-index" +import { getExplorerTxUrl } from "../../shared/chains" +import type { PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryStats, SwapTrackingStatus } from "../../shared/types" + +const ExternalLinkIcon = () => ( + + + + + +) + +// ── Stage helpers ─────────────────────────────────────────────────── + +function getStage(status: string): 1 | 2 | 3 { + switch (status) { + case 'signing': + case 'pending': + case 'confirming': + return 1 + case 'output_detected': + case 'output_confirming': + return 2 + default: + return 3 + } +} + +function getStatusColor(status: string): string { + switch (status) { + case 'signing': return '#A78BFA' + case 'pending': return '#FBBF24' + case 'confirming': return '#3B82F6' + case 'output_detected': return '#23DCC8' + case 'output_confirming': return '#3B82F6' + case 'output_confirmed': + case 'completed': return '#4ADE80' + case 'failed': return '#EF4444' + case 'refunded': return '#FB923C' + default: return '#9CA3AF' + } +} + +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m` +} + +function formatDate(ts: number): string { + const d = new Date(ts) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + + ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) +} + +const HISTORY_CSS = ` + @keyframes kkHistoryFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes kkSwapPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(35,220,200,0.5); } + 50% { box-shadow: 0 0 0 6px rgba(35,220,200,0); } + } +` + +type TabId = 'active' | 'history' +type StatusFilter = SwapTrackingStatus | 'all' + +// ── Stage indicator ───────────────────────────────────────────────── + +function StageIndicator({ stage, status }: { stage: 1 | 2 | 3; status: string }) { + const color = getStatusColor(status) + const isFinal = status === 'completed' || status === 'failed' || status === 'refunded' + + return ( + + {[1, 2, 3].map((s) => { + const isActive = s === stage + const isDone = s < stage || isFinal + const dotColor = isDone ? '#4ADE80' : isActive ? color : 'rgba(255,255,255,0.15)' + return ( + + + {s < 3 && ( + + )} + + ) + })} + + ) +} + +// ── Active Swap Card (live polling) ───────────────────────────────── + +function ActiveSwapCard({ swap, onDismiss, onResume }: { swap: PendingSwap; onDismiss: (txid: string) => void; onResume?: (swap: PendingSwap) => void }) { + const { t } = useTranslation("swap") + const stage = getStage(swap.status) + const color = getStatusColor(swap.status) + const isFinal = swap.status === 'completed' || swap.status === 'failed' || swap.status === 'refunded' + const [copied, setCopied] = useState(false) + + const [now, setNow] = useState(Date.now()) + useEffect(() => { + if (isFinal) return + const interval = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(interval) + }, [isFinal]) + const elapsed = now - swap.createdAt + + const statusLabel = t(`status${swap.status.charAt(0).toUpperCase()}${swap.status.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase())}` as any, swap.status) + + const copyTxid = () => { + navigator.clipboard.writeText(swap.txid) + .then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000) }) + .catch(() => {}) + } + + return ( + onResume?.(swap)} + > + + + {swap.fromSymbol} + + {swap.toSymbol} + + + {statusLabel} + + + + + {swap.fromAmount} {swap.fromSymbol} → ~{swap.expectedOutput} {swap.toSymbol} + + + + + + = 1 ? color : 'kk.textMuted'}>{t("stageInput")} + = 2 ? color : 'kk.textMuted'}>{t("stageProtocol")} + = 3 ? color : 'kk.textMuted'}>{t("stageOutput")} + + + {swap.status === 'confirming' && swap.confirmations > 0 && ( + {swap.confirmations} {t("confirmations")} + )} + + {swap.outboundConfirmations !== undefined && swap.outboundRequiredConfirmations !== undefined && ( + + + {t("outputConfirmations")} + + {swap.outboundConfirmations} / {swap.outboundRequiredConfirmations} + + + + + + + )} + + {swap.error && ( + {swap.error} + )} + + + + {t("elapsed")}: {formatElapsed(elapsed)} + {!isFinal && swap.estimatedTime > 0 && ` / ${t("estimated")} ${formatElapsed(swap.estimatedTime * 1000)}`} + + + + {(() => { + const url = getExplorerTxUrl(swap.fromChainId, swap.txid) + return url ? ( + + ) : null + })()} + {swap.outboundTxid && (() => { + const url = getExplorerTxUrl(swap.toChainId, swap.outboundTxid) + return url ? ( + + ) : null + })()} + {isFinal && ( + + )} + + + + ) +} + +// ── History Record Card (from SQLite) ─────────────────────────────── + +function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume?: (swap: PendingSwap) => void }) { + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(null) + const color = getStatusColor(record.status) + const isFinal = record.status === 'completed' || record.status === 'failed' || record.status === 'refunded' + + const copyValue = (val: string, label: string) => { + navigator.clipboard.writeText(val) + .then(() => { setCopied(label); setTimeout(() => setCopied(null), 2000) }) + .catch(() => {}) + } + + // Calculate quote accuracy for completed swaps + let quoteAccuracy: { diff: number; pct: string; positive: boolean } | null = null + if (record.status === 'completed' && record.receivedOutput && record.quotedOutput) { + const quoted = parseFloat(record.quotedOutput) + const received = parseFloat(record.receivedOutput) + if (quoted > 0 && received > 0) { + const diff = received - quoted + quoteAccuracy = { diff, pct: ((diff / quoted) * 100).toFixed(2), positive: diff >= 0 } + } + } + + const durationStr = record.actualTimeSeconds !== undefined + ? (record.actualTimeSeconds < 60 ? `${record.actualTimeSeconds}s` : `${Math.floor(record.actualTimeSeconds / 60)}m ${record.actualTimeSeconds % 60}s`) + : null + + return ( + setExpanded(!expanded)} + transition="all 0.15s" + _hover={{ borderColor: 'rgba(35,220,200,0.25)' }} + > + {/* Header row */} + + + + {record.fromSymbol} → {record.toSymbol} + + + {record.status} + + + {formatDate(record.createdAt)} + + + {/* Amounts row */} + + + {record.fromAmount} {record.fromSymbol} + {record.receivedOutput + ? <> → {record.receivedOutput} {record.toSymbol} + : <> → ~{record.quotedOutput} {record.toSymbol} (quoted) + } + + {durationStr && ( + {durationStr} + )} + + + {/* Quote accuracy badge */} + {quoteAccuracy && ( + + + {quoteAccuracy.positive ? '+' : ''}{quoteAccuracy.pct}% vs quote + + + )} + + {record.error && ( + {record.error} + )} + + {/* Expanded details */} + {expanded && ( + + + + + + + + + {record.receivedOutput && ( + + )} + + {record.actualTimeSeconds !== undefined && ( + + )} + + {/* TX IDs with copy + explorer buttons */} + + Inbound TX + + + {(() => { + const url = getExplorerTxUrl(record.fromChainId, record.txid) + return url ? ( + + ) : null + })()} + + + {record.outboundTxid && ( + + Outbound TX + + + {(() => { + const url = getExplorerTxUrl(record.toChainId, record.outboundTxid) + return url ? ( + + ) : null + })()} + + + )} + {record.approvalTxid && ( + + Approval TX + + + {(() => { + const url = getExplorerTxUrl(record.fromChainId, record.approvalTxid) + return url ? ( + + ) : null + })()} + + + )} + + {/* Resume / view swap button */} + {onResume && ( + + )} + + + )} + + ) +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ) +} + +// ── Status Filter Pills ───────────────────────────────────────────── + +const STATUS_OPTIONS: { id: StatusFilter; label: string; color: string }[] = [ + { id: 'all', label: 'All', color: '#9CA3AF' }, + { id: 'completed', label: 'Completed', color: '#4ADE80' }, + { id: 'failed', label: 'Failed', color: '#EF4444' }, + { id: 'refunded', label: 'Refunded', color: '#FB923C' }, + { id: 'pending', label: 'Pending', color: '#FBBF24' }, +] + +// ── Main SwapHistoryDialog ────────────────────────────────────────── + +interface SwapHistoryDialogProps { + open: boolean + onClose: () => void + onResumeSwap?: (swap: PendingSwap) => void +} + +export function SwapHistoryDialog({ open, onClose, onResumeSwap }: SwapHistoryDialogProps) { + const { t } = useTranslation("swap") + const [tab, setTab] = useState('active') + const [pendingSwaps, setPendingSwaps] = useState([]) + const [history, setHistory] = useState([]) + const [stats, setStats] = useState(null) + const [statusFilter, setStatusFilter] = useState('all') + const [searchQuery, setSearchQuery] = useState('') + const [exporting, setExporting] = useState<'pdf' | 'csv' | null>(null) + const [exportResult, setExportResult] = useState(null) + + // Fetch active pending swaps + const fetchPending = useCallback(() => { + rpcRequest('getPendingSwaps', undefined, 5000) + .then((result) => { if (result) setPendingSwaps(result) }) + .catch(() => {}) + }, []) + + // Fetch history from SQLite + const fetchHistory = useCallback(() => { + rpcRequest('getSwapHistory', { + status: statusFilter === 'all' ? undefined : statusFilter, + asset: searchQuery || undefined, + limit: 200, + }, 10000) + .then((result) => { if (result) setHistory(result) }) + .catch(() => {}) + + rpcRequest('getSwapHistoryStats', undefined, 5000) + .then((result) => { if (result) setStats(result) }) + .catch(() => {}) + }, [statusFilter, searchQuery]) + + // Initial load + useEffect(() => { + if (!open) return + fetchPending() + fetchHistory() + }, [open, fetchPending, fetchHistory]) + + // Listen for DOM events from SwapDialog + useEffect(() => { + const handler = () => { + fetchPending() + fetchHistory() + setTimeout(fetchPending, 1000) + setTimeout(fetchHistory, 2000) + } + window.addEventListener('keepkey-swap-executed', handler) + return () => window.removeEventListener('keepkey-swap-executed', handler) + }, [fetchPending, fetchHistory]) + + // Listen for RPC push updates + useEffect(() => { + const unsub1 = onRpcMessage('swap-update', (update: SwapStatusUpdate) => { + setPendingSwaps(prev => { + const idx = prev.findIndex(s => s.txid === update.txid) + if (idx === -1) { fetchPending(); return prev } + const updated = [...prev] + updated[idx] = { + ...updated[idx], + status: update.status, + updatedAt: Date.now(), + ...(update.confirmations !== undefined ? { confirmations: update.confirmations } : {}), + ...(update.outboundConfirmations !== undefined ? { outboundConfirmations: update.outboundConfirmations } : {}), + ...(update.outboundRequiredConfirmations !== undefined ? { outboundRequiredConfirmations: update.outboundRequiredConfirmations } : {}), + ...(update.outboundTxid ? { outboundTxid: update.outboundTxid } : {}), + ...(update.error ? { error: update.error } : {}), + } + return updated + }) + // Also refresh history on terminal status + if (update.status === 'completed' || update.status === 'failed' || update.status === 'refunded') { + setTimeout(fetchHistory, 500) + } + }) + + const unsub2 = onRpcMessage('swap-complete', () => { + fetchPending() + setTimeout(fetchHistory, 500) + }) + + return () => { unsub1(); unsub2() } + }, [fetchPending, fetchHistory]) + + // Poll active swaps + const activeSwaps = useMemo(() => + pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), + [pendingSwaps] + ) + + useEffect(() => { + if (!open || activeSwaps.length === 0) return + const interval = setInterval(fetchPending, 15000) + return () => clearInterval(interval) + }, [open, activeSwaps.length, fetchPending]) + + const handleDismiss = useCallback((txid: string) => { + rpcRequest('dismissSwap', { txid }).catch(() => {}) + setPendingSwaps(prev => prev.filter(s => s.txid !== txid)) + }, []) + + const handleExport = useCallback(async (format: 'pdf' | 'csv') => { + setExporting(format) + setExportResult(null) + try { + const result = await rpcRequest<{ filePath: string }>('exportSwapReport', { format }, 30000) + if (result?.filePath) { + setExportResult(result.filePath) + } + } catch (e: any) { + setExportResult(`Error: ${e.message || 'Export failed'}`) + } finally { + setExporting(null) + } + }, []) + + if (!open) return null + + const hasActive = activeSwaps.length > 0 || pendingSwaps.some(s => s.status === 'completed' || s.status === 'failed' || s.status === 'refunded') + + return ( + + + + 0 ? 'rgba(35,220,200,0.3)' : 'kk.border'} + borderRadius="xl" + w="620px" + maxW="95vw" + maxH="85vh" + display="flex" + flexDirection="column" + overflow="hidden" + onClick={(e) => e.stopPropagation()} + style={{ animation: 'kkHistoryFadeIn 0.2s ease-out' }} + > + {/* Header */} + + + {t("swapHistory")} + {stats && ( + + + {stats.completed} + + {stats.failed > 0 && ( + + {stats.failed} + + )} + + {stats.totalSwaps} total + + + )} + + + + + {/* Tabs */} + + + + + {/* Export buttons */} + {tab === 'history' && ( + + + + + )} + + + {/* Export result notification */} + {exportResult && ( + + + + {exportResult.startsWith('Error') ? exportResult : `Saved to ${exportResult}`} + + + + + )} + + {/* Body */} + + {tab === 'active' ? ( + /* Active swaps tab */ + pendingSwaps.length === 0 ? ( + + + No active swaps + + Completed swaps are in the History tab + + + ) : ( + + {activeSwaps.length > 0 && ( + <> + + + + {t("activeSwaps")} ({activeSwaps.length}) + + + {activeSwaps.map(swap => ( + + ))} + + )} + {/* Recently completed in active tab */} + {pendingSwaps.filter(s => s.status === 'completed' || s.status === 'failed' || s.status === 'refunded').length > 0 && ( + <> + {activeSwaps.length > 0 && } + + Recently Finished + + {pendingSwaps + .filter(s => s.status === 'completed' || s.status === 'failed' || s.status === 'refunded') + .map(swap => ( + + )) + } + + )} + + ) + ) : ( + /* History tab */ + + {/* Filters */} + + {STATUS_OPTIONS.map(opt => ( + + ))} + + + {/* Search */} + setSearchQuery(e.target.value)} + _placeholder={{ color: 'kk.textMuted' }} + _focus={{ borderColor: 'rgba(35,220,200,0.4)' }} + /> + + {/* History records */} + {history.length === 0 ? ( + + No swap history found + {statusFilter !== 'all' && ( + + )} + + ) : ( + history.map(record => ( + + )) + )} + + )} + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx new file mode 100644 index 0000000..407c01c --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx @@ -0,0 +1,173 @@ +/** + * SwapTracker — floating bubble that shows when swaps are active. + * + * Click to open SwapHistoryDialog for full swap details + history. + */ +import { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { useTranslation } from "react-i18next" +import { Box, Text } from "@chakra-ui/react" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { Z } from "../lib/z-index" +import { SwapHistoryDialog } from "./SwapHistoryDialog" +import { SwapDialog } from "./SwapDialog" +import type { PendingSwap, SwapStatusUpdate } from "../../shared/types" + +const TRACKER_CSS = ` + @keyframes kkTrackerPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(35,220,200,0.5); } + 50% { box-shadow: 0 0 0 8px rgba(35,220,200,0); } + } +` + +export function SwapTracker() { + const { t } = useTranslation("swap") + const [swaps, setSwaps] = useState([]) + const [historyOpen, setHistoryOpen] = useState(false) + const [hasNew, setHasNew] = useState(false) + const [resumeSwap, setResumeSwap] = useState(null) + const lastCountRef = useRef(0) + + const fetchSwaps = useCallback(() => { + rpcRequest('getPendingSwaps', undefined, 5000) + .then((result) => { + if (result) setSwaps(result) + }) + .catch(() => {}) + }, []) + + // Fetch on mount + useEffect(() => { fetchSwaps() }, [fetchSwaps]) + + // Listen for swap-executed DOM event from SwapDialog + useEffect(() => { + const handler = () => { + fetchSwaps() + setTimeout(fetchSwaps, 1000) + setTimeout(fetchSwaps, 3000) + } + window.addEventListener('keepkey-swap-executed', handler) + return () => window.removeEventListener('keepkey-swap-executed', handler) + }, [fetchSwaps]) + + // Listen for RPC push updates + useEffect(() => { + const unsub1 = onRpcMessage('swap-update', (update: SwapStatusUpdate) => { + setSwaps(prev => { + const idx = prev.findIndex(s => s.txid === update.txid) + if (idx === -1) { + fetchSwaps() + return prev + } + const updated = [...prev] + updated[idx] = { + ...updated[idx], + status: update.status, + updatedAt: Date.now(), + ...(update.confirmations !== undefined ? { confirmations: update.confirmations } : {}), + ...(update.outboundConfirmations !== undefined ? { outboundConfirmations: update.outboundConfirmations } : {}), + ...(update.outboundRequiredConfirmations !== undefined ? { outboundRequiredConfirmations: update.outboundRequiredConfirmations } : {}), + ...(update.outboundTxid ? { outboundTxid: update.outboundTxid } : {}), + ...(update.error ? { error: update.error } : {}), + } + return updated + }) + }) + + const unsub2 = onRpcMessage('swap-complete', (swap: PendingSwap) => { + setSwaps(prev => { + const idx = prev.findIndex(s => s.txid === swap.txid) + if (idx === -1) return [...prev, swap] + const updated = [...prev] + updated[idx] = swap + return updated + }) + // Trigger balance refresh for both chains when swap completes + if (swap.status === 'completed' || swap.status === 'refunded') { + window.dispatchEvent(new CustomEvent('keepkey-swap-completed', { + detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId } + })) + } + }) + + return () => { unsub1(); unsub2() } + }, [fetchSwaps]) + + // Detect new swaps for pulse animation + useEffect(() => { + if (swaps.length > lastCountRef.current && !historyOpen) { + setHasNew(true) + } + lastCountRef.current = swaps.length + }, [swaps.length, historyOpen]) + + // Poll while active swaps exist + const activeSwaps = useMemo(() => + swaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), + [swaps] + ) + + useEffect(() => { + if (swaps.length === 0) return + const interval = setInterval(fetchSwaps, 15000) + return () => clearInterval(interval) + }, [swaps.length, fetchSwaps]) + + const handleOpen = () => { + setHistoryOpen(true) + setHasNew(false) + } + + const handleResumeSwap = useCallback((swap: PendingSwap) => { + setHistoryOpen(false) + setResumeSwap(swap) + }, []) + + // Don't render if no swaps + if (swaps.length === 0) return null + + return ( + <> + + + {/* Floating bubble */} + + + {activeSwaps.length > 0 ? ( + + ) : ( + + )} + + {activeSwaps.length > 0 ? `${activeSwaps.length} swap${activeSwaps.length > 1 ? 's' : ''}` : t("activeSwaps")} + + + + + {/* History dialog */} + setHistoryOpen(false)} onResumeSwap={handleResumeSwap} /> + + {/* Resume swap dialog — opened from history */} + setResumeSwap(null)} + resumeSwap={resumeSwap} + /> + + ) +} diff --git a/projects/keepkey-vault/src/mainview/i18n/index.ts b/projects/keepkey-vault/src/mainview/i18n/index.ts index e754162..67497ed 100644 --- a/projects/keepkey-vault/src/mainview/i18n/index.ts +++ b/projects/keepkey-vault/src/mainview/i18n/index.ts @@ -15,6 +15,7 @@ import setup from "./locales/en/setup.json" import update from "./locales/en/update.json" import appstore from "./locales/en/appstore.json" import dialogs from "./locales/en/dialogs.json" +import swap from "./locales/en/swap.json" const STORAGE_KEY = "keepkey-vault-lang" @@ -47,6 +48,7 @@ i18n "update", "appstore", "dialogs", + "swap", ], resources: { en: { @@ -62,6 +64,7 @@ i18n update, appstore, dialogs, + swap, }, }, interpolation: { escapeValue: false }, diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json index 719b9dd..f5183d6 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json @@ -1,6 +1,7 @@ { "receive": "Receive", "send": "Send", + "swap": "Swap", "tokens": "Tokens", "tokenCount_one": "{{count}} token", "tokenCount_other": "{{count}} tokens", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/nav.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/nav.json index 01503cc..9740beb 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/nav.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/nav.json @@ -1,6 +1,7 @@ { "apps": "Apps", "keepkey": "KeepKey", + "swap": "Swap", "shapeshift": "ShapeShift", "watchOnly": "Watch Only", "deviceSettings": "Device settings", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json index 5fae6cc..ff9e82c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json @@ -43,6 +43,9 @@ "versionAvailable": "Version {{version}} available", "onLatestVersion": "You are on the latest version", "checkFailed": "Check failed", + "featureFlags": "Feature Flags", + "swapsFeature": "Cross-Chain Swaps", + "swapsFeatureDescription": "Enable token swaps via THORChain and other DEXes.", "language": "Language", "dangerZone": "Danger Zone", "wipeWarning": "Wiping erases all data on the device. Make sure you have your recovery phrase backed up.", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json new file mode 100644 index 0000000..83b9f57 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json @@ -0,0 +1,87 @@ +{ + "title": "Swap", + "from": "From", + "to": "To", + "selectAsset": "Select asset", + "searchAssets": "Search assets...", + "amount": "Amount", + "amountPlaceholder": "0.0", + "max": "MAX", + "available": "Available", + "getQuote": "Get Quote", + "gettingQuote": "Getting quote...", + "noQuote": "No quote available", + "expectedOutput": "Expected output", + "minimumReceived": "Minimum received", + "rate": "Rate", + "networkFee": "Network fee", + "slippage": "Slippage", + "estimatedTime": "Est. time", + "swap": "Swap", + "reviewSwap": "Review Swap", + "review": "Review Swap", + "confirmSwap": "Confirm Swap", + "signingOnDevice": "Confirm on device...", + "broadcasting": "Broadcasting...", + "broadcastingDesc": "Submitting your transaction to the network...", + "swapSuccess": "Swap Submitted", + "swapSubmitted": "Swap Submitted!", + "swapSubmittedDesc": "Your swap has been submitted to the network", + "txid": "Tx", + "viewInExplorer": "Track Swap", + "newSwap": "New Swap", + "done": "Done", + "close": "Close", + "poweredBy": "Powered by THORChain", + "noAssets": "No swappable assets found", + "loadingAssets": "Loading assets...", + "connectDevice": "Connect device to swap", + "errorQuote": "Failed to get quote", + "errorSwap": "Swap failed", + "insufficientBalance": "Insufficient balance", + "sameAsset": "Cannot swap the same asset", + "routerContract": "Router", + "vault": "Vault", + "minutes": "min", + "seconds": "sec", + "back": "Back", + "cancel": "Cancel", + "approvingToken": "Approving token...", + "approvalRequired": "Waiting for token approval on your KeepKey...", + "enterAmount": "Enter an amount to get a quote", + "notSupported": "{{coin}} swaps are not yet supported via THORChain.", + "copied": "Copied", + "copy": "Copy", + "youSend": "You Send", + "youReceive": "You Receive (estimated)", + "verifyOnDevice": "Address will be verified on your KeepKey device", + "confirmOnDevice": "Confirm on Device", + "confirmOnDeviceDesc": "Check your KeepKey and verify the transaction details", + "activeSwaps": "Active Swaps", + "noActiveSwaps": "No active swaps", + "swapHistory": "Swap History", + "noSwapHistory": "No swap history yet", + "completedSwaps": "Completed", + "statusSigning": "Signing", + "statusPending": "Pending", + "statusConfirming": "Confirming", + "statusOutputDetected": "Output Detected", + "statusOutputConfirming": "Output Confirming", + "statusCompleted": "Completed", + "statusFailed": "Failed", + "statusRefunded": "Refunded", + "stageInput": "Input Transaction", + "stageProtocol": "Protocol Processing", + "stageOutput": "Output Transaction", + "confirmations": "confirmations", + "outputConfirmations": "Output Confirmations", + "elapsed": "Elapsed", + "estimated": "Est.", + "dismiss": "Dismiss", + "swapCompleted": "Swap completed!", + "swapFailed": "Swap failed", + "trackingSwap": "Tracking swap via THORChain...", + "waitingForConfirmations": "Waiting for confirmations — swap is NOT complete yet", + "switchToFiat": "Switch to fiat input", + "switchToCrypto": "Switch to crypto input" +} diff --git a/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx b/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx new file mode 100644 index 0000000..a866235 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx @@ -0,0 +1,85 @@ +import { createContext, useContext, useState, useCallback, useEffect } from "react" +import type { FiatCurrency, AppSettings } from "../../shared/types" +import { formatFiat, formatFiatCompact, getFiatConfig } from "../../shared/fiat" +import { rpcRequest } from "./rpc" + +interface FiatContextValue { + currency: FiatCurrency + locale: string + /** Format USD value into the user's chosen fiat currency */ + fmt: (usdValue: number | string | null | undefined) => string + /** Compact format (narrowSymbol) for inline display */ + fmtCompact: (usdValue: number | string | null | undefined) => string + /** Currency symbol (e.g. "$", "\u20AC") */ + symbol: string + /** Update fiat currency preference */ + setCurrency: (currency: FiatCurrency) => void + /** Update number locale preference */ + setLocale: (locale: string) => void +} + +const FiatContext = createContext({ + currency: 'USD', + locale: 'en-US', + fmt: () => '', + fmtCompact: () => '', + symbol: '$', + setCurrency: () => {}, + setLocale: () => {}, +}) + +export function useFiat() { + return useContext(FiatContext) +} + +export function FiatProvider({ children }: { children: React.ReactNode }) { + const [currency, setCurrencyState] = useState(() => { + try { + return (localStorage.getItem('keepkey-vault-fiat') as FiatCurrency) || 'USD' + } catch { return 'USD' } + }) + const [locale, setLocaleState] = useState(() => { + try { + return localStorage.getItem('keepkey-vault-locale') || 'en-US' + } catch { return 'en-US' } + }) + + // Load from backend settings on mount + useEffect(() => { + rpcRequest('getAppSettings') + .then(s => { + if (s.fiatCurrency) setCurrencyState(s.fiatCurrency) + if (s.numberLocale) setLocaleState(s.numberLocale) + }) + .catch(() => {}) + }, []) + + const setCurrency = useCallback((c: FiatCurrency) => { + setCurrencyState(c) + try { localStorage.setItem('keepkey-vault-fiat', c) } catch {} + // Persist to backend + rpcRequest('setFiatCurrency', { currency: c }).catch(() => {}) + }, []) + + const setLocale = useCallback((l: string) => { + setLocaleState(l) + try { localStorage.setItem('keepkey-vault-locale', l) } catch {} + rpcRequest('setNumberLocale', { locale: l }).catch(() => {}) + }, []) + + const cfg = getFiatConfig(currency) + + const fmt = useCallback((usdValue: number | string | null | undefined) => { + return formatFiat(usdValue, currency, locale) + }, [currency, locale]) + + const fmtCompact = useCallback((usdValue: number | string | null | undefined) => { + return formatFiatCompact(usdValue, currency, locale) + }, [currency, locale]) + + return ( + + {children} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/lib/formatting.ts b/projects/keepkey-vault/src/mainview/lib/formatting.ts index e18b127..6c5411e 100644 --- a/projects/keepkey-vault/src/mainview/lib/formatting.ts +++ b/projects/keepkey-vault/src/mainview/lib/formatting.ts @@ -2,13 +2,15 @@ export function formatBalance(val: string): string { const num = parseFloat(val) if (isNaN(num) || num === 0) return '0' - if (num < 0.000001) return num.toExponential(2) - if (num < 1) return num.toFixed(6) - if (num < 1000) return num.toFixed(4) - return num.toLocaleString(undefined, { maximumFractionDigits: 2 }) + const abs = Math.abs(num) + const sign = num < 0 ? '-' : '' + if (abs < 0.000001) return num.toExponential(2) + if (abs < 1) return sign + abs.toFixed(6) + if (abs < 1000) return sign + abs.toFixed(4) + return sign + abs.toLocaleString(undefined, { maximumFractionDigits: 2 }) } -/** Format a USD value for display (e.g. "1,234.56"). */ +/** Format a USD value for display (e.g. "1,234.56"). Legacy — prefer useFiat().fmt() */ export function formatUsd(value: number | string | null | undefined): string { if (value === null || value === undefined) return '0.00' const num = typeof value === 'string' ? parseFloat(value) : value diff --git a/projects/keepkey-vault/src/mainview/main.tsx b/projects/keepkey-vault/src/mainview/main.tsx index 23257e3..47823ab 100644 --- a/projects/keepkey-vault/src/mainview/main.tsx +++ b/projects/keepkey-vault/src/mainview/main.tsx @@ -6,6 +6,7 @@ import "./index.css" import "./i18n" import splashBg from "./assets/splash-bg.png" import App from "./App" +import { FiatProvider } from "./lib/fiat-context" // Global error handler — prevent stray promise rejections from crashing the WebView window.addEventListener('unhandledrejection', (e) => { @@ -19,7 +20,9 @@ document.body.style.background = `#000000 url(${splashBg}) center / cover no-rep createRoot(document.getElementById("root")!).render( - + + + , ) diff --git a/projects/keepkey-vault/src/shared/chains.ts b/projects/keepkey-vault/src/shared/chains.ts index 1587363..cee4b21 100644 --- a/projects/keepkey-vault/src/shared/chains.ts +++ b/projects/keepkey-vault/src/shared/chains.ts @@ -51,48 +51,64 @@ const CONFIGS: ChainConfig[] = [ chainFamily: 'utxo', color: '#F7931A', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000000, 0x80000000, 0, 0], scriptType: 'p2pkh', + explorerTxUrl: 'https://blockchair.com/bitcoin/transaction/{{txid}}', + explorerAddressUrl: 'https://blockchair.com/bitcoin/address/{{address}}', }, { id: 'ethereum', chain: Chain.Ethereum, coin: 'Ethereum', symbol: 'ETH', chainFamily: 'evm', color: '#627EEA', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '1', + explorerTxUrl: 'https://etherscan.io/tx/{{txid}}', + explorerAddressUrl: 'https://etherscan.io/address/{{address}}', }, { id: 'polygon', chain: Chain.Polygon, coin: 'Polygon', symbol: 'MATIC', chainFamily: 'evm', color: '#8247E5', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '137', + explorerTxUrl: 'https://polygonscan.com/tx/{{txid}}', + explorerAddressUrl: 'https://polygonscan.com/address/{{address}}', }, { id: 'arbitrum', chain: Chain.Arbitrum, coin: 'Arbitrum', symbol: 'ETH', chainFamily: 'evm', color: '#28A0F0', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '42161', + explorerTxUrl: 'https://arbiscan.io/tx/{{txid}}', + explorerAddressUrl: 'https://arbiscan.io/address/{{address}}', }, { id: 'optimism', chain: Chain.Optimism, coin: 'Optimism', symbol: 'ETH', chainFamily: 'evm', color: '#FF0420', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '10', + explorerTxUrl: 'https://optimistic.etherscan.io/tx/{{txid}}', + explorerAddressUrl: 'https://optimistic.etherscan.io/address/{{address}}', }, { id: 'avalanche', chain: Chain.Avalanche, coin: 'Avalanche', symbol: 'AVAX', chainFamily: 'evm', color: '#E84142', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '43114', + explorerTxUrl: 'https://snowtrace.io/tx/{{txid}}', + explorerAddressUrl: 'https://snowtrace.io/address/{{address}}', }, { id: 'bsc', chain: Chain.BinanceSmartChain, coin: 'BNB Smart Chain', symbol: 'BNB', chainFamily: 'evm', color: '#F0B90B', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '56', + explorerTxUrl: 'https://bscscan.com/tx/{{txid}}', + explorerAddressUrl: 'https://bscscan.com/address/{{address}}', }, { id: 'base', chain: Chain.Base, coin: 'Base', symbol: 'ETH', chainFamily: 'evm', color: '#0052FF', rpcMethod: 'ethGetAddress', signMethod: 'ethSignTx', defaultPath: [0x8000002C, 0x8000003C, 0x80000000, 0, 0], chainId: '8453', + explorerTxUrl: 'https://basescan.org/tx/{{txid}}', + explorerAddressUrl: 'https://basescan.org/address/{{address}}', }, { id: 'monad', chain: Chain.Monad, coin: 'Monad', symbol: 'MON', @@ -112,6 +128,8 @@ const CONFIGS: ChainConfig[] = [ rpcMethod: 'cosmosGetAddress', signMethod: 'cosmosSignTx', defaultPath: [0x8000002C, 0x80000076, 0x80000000, 0, 0], denom: 'uatom', chainId: 'cosmoshub-4', + explorerTxUrl: 'https://www.mintscan.io/cosmos/tx/{{txid}}', + explorerAddressUrl: 'https://www.mintscan.io/cosmos/address/{{address}}', }, { id: 'thorchain', chain: Chain.THORChain, coin: 'THORChain', symbol: 'RUNE', @@ -119,6 +137,8 @@ const CONFIGS: ChainConfig[] = [ rpcMethod: 'thorchainGetAddress', signMethod: 'thorchainSignTx', defaultPath: [0x8000002C, 0x800003A3, 0x80000000, 0, 0], denom: 'rune', chainId: 'thorchain-1', + explorerTxUrl: 'https://runescan.io/tx/{{txid}}', + explorerAddressUrl: 'https://runescan.io/address/{{address}}', }, { id: 'mayachain', chain: Chain.Mayachain, coin: 'Mayachain', symbol: 'CACAO', @@ -126,6 +146,8 @@ const CONFIGS: ChainConfig[] = [ rpcMethod: 'mayachainGetAddress', signMethod: 'mayachainSignTx', defaultPath: [0x8000002C, 0x800003A3, 0x80000000, 0, 0], denom: 'cacao', chainId: 'mayachain-mainnet-v1', + explorerTxUrl: 'https://www.mayascan.org/tx/{{txid}}', + explorerAddressUrl: 'https://www.mayascan.org/address/{{address}}', }, { id: 'osmosis', chain: Chain.Osmosis, coin: 'Osmosis', symbol: 'OSMO', @@ -133,30 +155,40 @@ const CONFIGS: ChainConfig[] = [ rpcMethod: 'osmosisGetAddress', signMethod: 'osmosisSignTx', defaultPath: [0x8000002C, 0x80000076, 0x80000000, 0, 0], denom: 'uosmo', chainId: 'osmosis-1', + explorerTxUrl: 'https://www.mintscan.io/osmosis/tx/{{txid}}', + explorerAddressUrl: 'https://www.mintscan.io/osmosis/address/{{address}}', }, { id: 'litecoin', chain: Chain.Litecoin, coin: 'Litecoin', symbol: 'LTC', chainFamily: 'utxo', color: '#BFBBBB', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000002, 0x80000000, 0, 0], scriptType: 'p2wpkh', + explorerTxUrl: 'https://blockchair.com/litecoin/transaction/{{txid}}', + explorerAddressUrl: 'https://blockchair.com/litecoin/address/{{address}}', }, { id: 'dogecoin', chain: Chain.Dogecoin, coin: 'Dogecoin', symbol: 'DOGE', chainFamily: 'utxo', color: '#C2A633', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000003, 0x80000000, 0, 0], scriptType: 'p2pkh', + explorerTxUrl: 'https://blockchair.com/dogecoin/transaction/{{txid}}', + explorerAddressUrl: 'https://blockchair.com/dogecoin/address/{{address}}', }, { id: 'bitcoincash', chain: Chain.BitcoinCash, coin: 'BitcoinCash', symbol: 'BCH', chainFamily: 'utxo', color: '#0AC18E', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000091, 0x80000000, 0, 0], scriptType: 'p2pkh', + explorerTxUrl: 'https://blockchair.com/bitcoin-cash/transaction/{{txid}}', + explorerAddressUrl: 'https://blockchair.com/bitcoin-cash/address/{{address}}', }, { id: 'dash', chain: Chain.Dash, coin: 'Dash', symbol: 'DASH', chainFamily: 'utxo', color: '#008CE7', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000005, 0x80000000, 0, 0], scriptType: 'p2pkh', + explorerTxUrl: 'https://blockchair.com/dash/transaction/{{txid}}', + explorerAddressUrl: 'https://blockchair.com/dash/address/{{address}}', }, { id: 'zcash', chain: Chain.Zcash, coin: 'Zcash', symbol: 'ZEC', @@ -178,12 +210,16 @@ const CONFIGS: ChainConfig[] = [ chainFamily: 'utxo', color: '#315BCA', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000014, 0x80000000, 0, 0], scriptType: 'p2pkh', + explorerTxUrl: 'https://digiexplorer.info/tx/{{txid}}', + explorerAddressUrl: 'https://digiexplorer.info/address/{{address}}', }, { id: 'ripple', chain: Chain.Ripple, coin: 'Ripple', symbol: 'XRP', chainFamily: 'xrp', color: '#23292F', rpcMethod: 'xrpGetAddress', signMethod: 'xrpSignTx', defaultPath: [0x8000002C, 0x80000090, 0x80000000, 0, 0], + explorerTxUrl: 'https://xrpscan.com/tx/{{txid}}', + explorerAddressUrl: 'https://xrpscan.com/account/{{address}}', }, { id: 'solana', chain: Chain.Solana, coin: 'Solana', symbol: 'SOL', @@ -207,6 +243,13 @@ export const CHAINS: ChainDef[] = CONFIGS.map(c => ({ decimals: BaseDecimal[c.chain as keyof typeof BaseDecimal] ?? DECIMAL_FALLBACKS[c.chain] ?? 8, })) +/** Get explorer TX URL for a chain ID + txid. Returns null if no explorer configured. */ +export function getExplorerTxUrl(chainId: string, txid: string): string | null { + const chain = CHAINS.find(c => c.id === chainId) + if (!chain?.explorerTxUrl) return null + return chain.explorerTxUrl.replace('{{txid}}', txid) +} + /** Check if a chain is supported by the given firmware version. Chains without minFirmware are always supported. */ export function isChainSupported(chain: ChainDef, firmwareVersion?: string): boolean { if (!chain.minFirmware) return true diff --git a/projects/keepkey-vault/src/shared/fiat.ts b/projects/keepkey-vault/src/shared/fiat.ts new file mode 100644 index 0000000..a7a6495 --- /dev/null +++ b/projects/keepkey-vault/src/shared/fiat.ts @@ -0,0 +1,90 @@ +import type { FiatCurrency } from './types' + +export interface FiatConfig { + code: FiatCurrency + symbol: string + name: string + locale: string // default Intl locale for this currency + decimals: number // typically 2, JPY/KRW = 0 +} + +export const FIAT_CURRENCIES: FiatConfig[] = [ + { code: 'USD', symbol: '$', name: 'US Dollar', locale: 'en-US', decimals: 2 }, + { code: 'EUR', symbol: '\u20AC', name: 'Euro', locale: 'de-DE', decimals: 2 }, + { code: 'GBP', symbol: '\u00A3', name: 'British Pound', locale: 'en-GB', decimals: 2 }, + { code: 'JPY', symbol: '\u00A5', name: 'Japanese Yen', locale: 'ja-JP', decimals: 0 }, + { code: 'CHF', symbol: 'CHF', name: 'Swiss Franc', locale: 'de-CH', decimals: 2 }, + { code: 'CAD', symbol: 'CA$', name: 'Canadian Dollar', locale: 'en-CA', decimals: 2 }, + { code: 'AUD', symbol: 'A$', name: 'Australian Dollar', locale: 'en-AU', decimals: 2 }, + { code: 'CNY', symbol: '\u00A5', name: 'Chinese Yuan', locale: 'zh-CN', decimals: 2 }, + { code: 'KRW', symbol: '\u20A9', name: 'South Korean Won', locale: 'ko-KR', decimals: 0 }, + { code: 'BRL', symbol: 'R$', name: 'Brazilian Real', locale: 'pt-BR', decimals: 2 }, + { code: 'RUB', symbol: '\u20BD', name: 'Russian Ruble', locale: 'ru-RU', decimals: 2 }, + { code: 'INR', symbol: '\u20B9', name: 'Indian Rupee', locale: 'en-IN', decimals: 2 }, + { code: 'MXN', symbol: 'MX$', name: 'Mexican Peso', locale: 'es-MX', decimals: 2 }, + { code: 'SEK', symbol: 'kr', name: 'Swedish Krona', locale: 'sv-SE', decimals: 2 }, + { code: 'NOK', symbol: 'kr', name: 'Norwegian Krone', locale: 'nb-NO', decimals: 2 }, + { code: 'DKK', symbol: 'kr', name: 'Danish Krone', locale: 'da-DK', decimals: 2 }, + { code: 'PLN', symbol: 'z\u0142', name: 'Polish Zloty', locale: 'pl-PL', decimals: 2 }, + { code: 'CZK', symbol: 'K\u010D', name: 'Czech Koruna', locale: 'cs-CZ', decimals: 2 }, + { code: 'HUF', symbol: 'Ft', name: 'Hungarian Forint', locale: 'hu-HU', decimals: 0 }, + { code: 'TRY', symbol: '\u20BA', name: 'Turkish Lira', locale: 'tr-TR', decimals: 2 }, +] + +export function getFiatConfig(code: FiatCurrency): FiatConfig { + return FIAT_CURRENCIES.find(c => c.code === code) || FIAT_CURRENCIES[0] +} + +/** + * Format a fiat value with locale-aware separators and currency symbol. + * All prices in the app are stored as USD — this applies a conversion rate. + */ +export function formatFiat( + usdValue: number | string | null | undefined, + currency: FiatCurrency, + locale: string, + conversionRate = 1, +): string { + if (usdValue === null || usdValue === undefined) return '' + const num = (typeof usdValue === 'string' ? parseFloat(usdValue) : usdValue) * conversionRate + if (!isFinite(num)) return '' + const cfg = getFiatConfig(currency) + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: cfg.decimals, + maximumFractionDigits: cfg.decimals, + }).format(num) + } catch { + // Fallback if locale not supported + return `${cfg.symbol}${num.toFixed(cfg.decimals)}` + } +} + +/** + * Format a fiat value compactly (no currency name, just symbol + number). + * For inline display next to crypto amounts. + */ +export function formatFiatCompact( + usdValue: number | string | null | undefined, + currency: FiatCurrency, + locale: string, + conversionRate = 1, +): string { + if (usdValue === null || usdValue === undefined) return '' + const num = (typeof usdValue === 'string' ? parseFloat(usdValue) : usdValue) * conversionRate + if (!isFinite(num) || num === 0) return '' + const cfg = getFiatConfig(currency) + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: cfg.decimals, + maximumFractionDigits: cfg.decimals, + currencyDisplay: 'narrowSymbol', + }).format(num) + } catch { + return `${cfg.symbol}${num.toFixed(cfg.decimals)}` + } +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index b9075f9..f485001 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -1,5 +1,5 @@ import type { ElectrobunRPCSchema } from 'electrobun/bun' -import type { DeviceStateInfo, FirmwareProgress, FirmwareAnalysis, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, BtcGetAddressParams, EthGetAddressParams, EthSignTxParams, BtcSignTxParams, GetPublicKeysParams, UpdateInfo, UpdateStatus, TokenVisibilityStatus, PairingRequestInfo, PairedAppInfo, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, ReportMeta, ReportData } from './types' +import type { DeviceStateInfo, FirmwareProgress, FirmwareAnalysis, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, BtcGetAddressParams, EthGetAddressParams, EthSignTxParams, BtcSignTxParams, GetPublicKeysParams, UpdateInfo, UpdateStatus, TokenVisibilityStatus, PairingRequestInfo, PairedAppInfo, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, ReportMeta, ReportData, SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult, PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryFilter, SwapHistoryStats } from './types' /** * RPC Schema for Bun ↔ WebView communication. @@ -125,6 +125,9 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { getAppSettings: { params: void; response: AppSettings } setRestApiEnabled: { params: { enabled: boolean }; response: AppSettings } setPioneerApiBase: { params: { url: string }; response: AppSettings } + setFiatCurrency: { params: { currency: string }; response: AppSettings } + setNumberLocale: { params: { locale: string }; response: AppSettings } + setSwapsEnabled: { params: { enabled: boolean }; response: AppSettings } // ── Reports ────────────────────────────────────────────────────── generateReport: { params: void; response: ReportMeta } @@ -133,6 +136,18 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { deleteReport: { params: { id: string }; response: void } saveReportFile: { params: { id: string; format: 'pdf' | 'cointracker' | 'zenledger' }; response: { filePath: string } } + // ── Swap ────────────────────────────────────────────────────────── + getSwapAssets: { params: void; response: SwapAsset[] } + getSwapQuote: { params: SwapQuoteParams; response: SwapQuote } + executeSwap: { params: ExecuteSwapParams; response: SwapResult } + getPendingSwaps: { params: void; response: PendingSwap[] } + dismissSwap: { params: { txid: string }; response: void } + + // ── Swap History (SQLite-persisted) ───────────────────────────── + getSwapHistory: { params: SwapHistoryFilter | void; response: SwapHistoryRecord[] } + getSwapHistoryStats: { params: void; response: SwapHistoryStats } + exportSwapReport: { params: { fromDate?: number; toDate?: number; format: 'pdf' | 'csv' }; response: { filePath: string } } + // ── Balance cache (instant portfolio) ───────────────────────────── getCachedBalances: { params: void; response: { balances: ChainBalance[]; updatedAt: number } | null } @@ -178,6 +193,8 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 'api-log': ApiLogEntry 'report-progress': { id: string; message: string; percent: number } 'walletconnect-uri': string + 'swap-update': SwapStatusUpdate + 'swap-complete': PendingSwap 'scan-progress': { percent: number; scannedHeight: number; tipHeight: number; blocksPerSec: number; etaSeconds: number } } } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index cef30af..4abc1d3 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -108,6 +108,7 @@ export interface BuildTxParams { memo?: string feeLevel?: number // 1=slow, 5=avg, 10=fast isMax?: boolean + isSwapDeposit?: boolean // THORChain/Maya: use MsgDeposit instead of MsgSend (for swaps/LP) caip?: string // Token CAIP-19 — triggers token transfer mode when contains 'erc20' tokenBalance?: string // human-readable token balance (from frontend) — avoids re-fetch on max send tokenDecimals?: number // token decimals (from frontend) — avoids re-fetch @@ -266,10 +267,16 @@ export interface ApiLogEntry { responseBody?: any // parsed JSON response } +// Supported fiat currencies +export type FiatCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'CHF' | 'CAD' | 'AUD' | 'CNY' | 'KRW' | 'BRL' | 'RUB' | 'INR' | 'MXN' | 'SEK' | 'NOK' | 'DKK' | 'PLN' | 'CZK' | 'HUF' | 'TRY' + // Application-level settings (persisted in SQLite) export interface AppSettings { restApiEnabled: boolean // controls entire REST API server on/off pioneerApiBase: string // current Pioneer API base URL + fiatCurrency: FiatCurrency // display currency (default 'USD') + numberLocale: string // number formatting locale (default 'en-US') + swapsEnabled: boolean // feature flag: cross-chain swaps (default OFF) } // ── RPC param/response types for top-use endpoints ────────────────────── @@ -362,6 +369,168 @@ export type ReportSection = | { title: string; type: 'list'; data: string[] } | { title: string; type: 'text'; data: string } +// ── Swap types ───────────────────────────────────────────────────────── + +/** An asset available for swapping (THORChain pool asset) */ +export interface SwapAsset { + asset: string // THORChain asset name (e.g. "BTC.BTC", "ETH.USDT-0xDAC...") + chainId: string // our chain id (e.g. "bitcoin", "ethereum") + symbol: string // display symbol ("BTC", "USDT") + name: string // display name ("Bitcoin", "Tether USD") + chainFamily: 'utxo' | 'evm' | 'cosmos' | 'xrp' + decimals: number + caip?: string // CAIP-19 if known + icon?: string // icon URL + contractAddress?: string // for ERC-20 tokens +} + +/** Quote response from Pioneer (aggregated across DEXes) */ +export interface SwapQuote { + expectedOutput: string // human-readable amount out + minimumOutput: string // after slippage + inboundAddress: string // vault address to send to + router?: string // EVM router contract (for depositWithExpiry) + memo: string // THORChain routing memo (empty for memoless integrations) + expiry?: number // unix timestamp — deadline for depositWithExpiry + fees: { + affiliate: string // affiliate fee (human-readable) + outbound: string // outbound gas fee + totalBps: number // total fee in basis points + } + estimatedTime: number // seconds + warning?: string // streaming swap note, dust threshold, etc. + slippageBps: number // actual slippage in bps + fromAsset: string // THORChain asset identifier + toAsset: string // THORChain asset identifier + integration?: string // DEX source: "thorchain", "shapeshift", "chainflip", etc. +} + +/** Parameters for getSwapQuote RPC */ +export interface SwapQuoteParams { + fromAsset: string // THORChain asset id (converted to CAIP in swap.ts for Pioneer) + toAsset: string // THORChain asset id (converted to CAIP in swap.ts for Pioneer) + amount: string // human-readable amount + fromAddress: string // sender address + toAddress: string // destination address + slippageBps?: number // slippage tolerance (default 300 = 3%) +} + +/** Parameters for executeSwap RPC */ +export interface ExecuteSwapParams { + fromChainId: string // our chain id + toChainId: string // our chain id + fromAsset: string // THORChain asset id + toAsset: string // THORChain asset id + amount: string // human-readable amount + memo: string // THORChain routing memo + inboundAddress: string // vault address + router?: string // EVM router (for token approvals) + expiry?: number // unix timestamp for depositWithExpiry + expectedOutput: string // for display + isMax?: boolean + feeLevel?: number +} + +/** Result of executeSwap RPC */ +export interface SwapResult { + txid: string + fromAsset: string + toAsset: string + fromAmount: string + expectedOutput: string + approvalTxid?: string +} + +// ── Swap tracking types ─────────────────────────────────────────────── + +export type SwapTrackingStatus = 'signing' | 'pending' | 'confirming' | 'output_detected' | 'output_confirming' | 'output_confirmed' | 'completed' | 'failed' | 'refunded' + +export interface PendingSwap { + txid: string + fromAsset: string // THORChain asset id (e.g. "BASE.ETH") + toAsset: string // THORChain asset id (e.g. "ETH.ETH") + fromSymbol: string + toSymbol: string + fromChainId: string // our chain id + toChainId: string + fromAmount: string // human-readable + expectedOutput: string // human-readable + memo: string + inboundAddress: string + router?: string + integration: string // "thorchain", "shapeshift", etc. + status: SwapTrackingStatus + confirmations: number + outboundConfirmations?: number + outboundRequiredConfirmations?: number + outboundTxid?: string + createdAt: number // unix ms + updatedAt: number // unix ms + estimatedTime: number // seconds + error?: string +} + +export interface SwapStatusUpdate { + txid: string + status: SwapTrackingStatus + confirmations?: number + outboundConfirmations?: number + outboundRequiredConfirmations?: number + outboundTxid?: string + error?: string +} + +/** Persisted swap history record (SQLite) — tracks the full lifecycle */ +export interface SwapHistoryRecord { + id: string // unique row id (UUID) + txid: string // inbound transaction hash + fromAsset: string // THORChain asset id + toAsset: string + fromSymbol: string + toSymbol: string + fromChainId: string + toChainId: string + fromAmount: string // human-readable amount sent + quotedOutput: string // expected output at quote time + minimumOutput: string // minimum after slippage at quote time + receivedOutput?: string // actual received (filled on completion) + slippageBps: number // slippage tolerance used + feeBps: number // total fee in basis points + feeOutbound: string // outbound gas fee quoted + integration: string // "thorchain", "shapeshift", "chainflip" + memo: string + inboundAddress: string // vault address + router?: string + status: SwapTrackingStatus + outboundTxid?: string + error?: string + createdAt: number // unix ms — when swap was initiated + updatedAt: number // unix ms — last status update + completedAt?: number // unix ms — when terminal status reached + estimatedTimeSeconds: number // estimated time at quote time + actualTimeSeconds?: number // actual duration (completedAt - createdAt) + approvalTxid?: string // ERC-20 approval tx (if applicable) +} + +/** Filter params for getSwapHistory RPC */ +export interface SwapHistoryFilter { + status?: SwapTrackingStatus | 'all' + fromDate?: number // unix ms + toDate?: number // unix ms + asset?: string // filter by fromAsset or toAsset containing this + limit?: number + offset?: number +} + +/** Stats summary for swap history */ +export interface SwapHistoryStats { + totalSwaps: number + completed: number + failed: number + refunded: number + pending: number +} + // RPC types — derived from the single source of truth in rpc-schema.ts // Import VaultRPCSchema from './rpc-schema' if you need the full Electrobun schema. // These aliases are for convenience in frontend code that doesn't need Electrobun types.