From 194f63e875c15b860c8a659eb2a120131ad4f547 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 9 Mar 2026 00:34:39 -0600 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20passphrase=20flow=20=E2=80=94=20co?= =?UTF-8?q?nfirm-on-device=20state,=20auto-dismiss,=20settings=20access=20?= =?UTF-8?q?in=20watch-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine: auto-trigger promptPin() on needs_passphrase so device sends PASSPHRASE_REQUEST - Engine: route applySettings through updateState so passphrase toggle triggers full flow - PassphraseEntry: show "Confirm on your KeepKey" spinner after submit instead of dismissing - App: auto-dismiss passphrase overlay when device transitions away from needs_passphrase - App: show needs_pin/needs_passphrase as splash (not ready) to prevent dashboard flash - App: enable settings gear + drawer in watch-only/claimed mode - TopNav: remove watchOnly disable on settings button - i18n: add passphrase.confirmOnDevice strings (10 languages) - i18n: enable partialBundledLanguages for graceful fallback - Bump version to 1.1.2 --- projects/keepkey-vault/package.json | 2 +- .../src/bun/engine-controller.ts | 16 +- projects/keepkey-vault/src/mainview/App.tsx | 27 ++- .../src/mainview/components/TopNav.tsx | 6 +- .../components/device/PassphraseEntry.tsx | 215 ++++++++++-------- .../keepkey-vault/src/mainview/i18n/index.ts | 1 + .../src/mainview/i18n/locales/de/device.json | 4 +- .../src/mainview/i18n/locales/en/device.json | 4 +- .../src/mainview/i18n/locales/es/device.json | 4 +- .../src/mainview/i18n/locales/fr/device.json | 4 +- .../src/mainview/i18n/locales/it/device.json | 4 +- .../src/mainview/i18n/locales/ja/device.json | 4 +- .../src/mainview/i18n/locales/ko/device.json | 4 +- .../src/mainview/i18n/locales/pt/device.json | 4 +- .../src/mainview/i18n/locales/ru/device.json | 4 +- .../src/mainview/i18n/locales/zh/device.json | 4 +- 16 files changed, 191 insertions(+), 116 deletions(-) diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index cf9f6c1..2b05bc9 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.1.1", + "version": "1.1.2", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev", diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index e216a1f..1a28635 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -235,6 +235,16 @@ export class EngineController extends EventEmitter { }) }, delay) } + + // Same for needs_passphrase — device has passphrase protection but PIN is + // already cached (or disabled). We must call promptPin() → getPublicKeys() + // so the device sends PASSPHRASE_REQUEST; without it, the UI overlay shows + // but sendPassphrase() has no pending device request to respond to. + if (state === 'needs_passphrase' && !this.promptPinActive) { + this.promptPin().catch(err => { + console.warn('[Engine] Auto prompt-passphrase failed:', err?.message) + }) + } } // ── Firmware Manifest ────────────────────────────────────────────────── @@ -881,7 +891,11 @@ export class EngineController extends EventEmitter { if (opts.autoLockDelayMs !== undefined) settings.autoLockDelayMs = opts.autoLockDelayMs await this.wallet.applySettings(settings) this.cachedFeatures = await this.wallet.getFeatures() - this.emit('state-change', this.getDeviceState()) + // Route through updateState so needs_passphrase triggers promptPin() → + // getPublicKeys() → PASSPHRASE_REQUEST. Previously this emitted directly, + // so enabling passphrase from settings showed the overlay but the device + // never received the passphrase (no pending request to respond to). + this.updateState(this.deriveState(this.cachedFeatures)) } async changePin() { diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 516cc90..c99481b 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -113,16 +113,21 @@ function App() { const handlePassphraseSubmit = useCallback(async (passphrase: string) => { try { await rpcRequest("sendPassphrase", { passphrase }) } catch (e) { console.error("sendPassphrase:", e) } - setPinRequestType(null) // ensure PIN overlay stays cleared - setPassphraseRequested(false) + setPinRequestType(null) + // Don't dismiss overlay here — sendPassphrase returns instantly (omitLock/noWait) + // but the device still needs physical confirmation. The overlay stays visible + // showing "Confirm on your KeepKey" until state transitions to 'ready'. }, []) const handlePassphraseCancel = useCallback(() => { setPinRequestType(null); setPassphraseRequested(false) }, []) - // Auto-show passphrase overlay when device needs passphrase + // Auto-show passphrase overlay when device needs passphrase; + // auto-dismiss when device leaves needs_passphrase (e.g. → ready). useEffect(() => { if (deviceState.state === "needs_passphrase" && !passphraseRequested) { setPassphraseRequested(true) + } else if (deviceState.state !== "needs_passphrase" && passphraseRequested) { + setPassphraseRequested(false) } }, [deviceState.state, passphraseRequested]) @@ -404,7 +409,8 @@ function App() { : isClaimed ? "claimed" : ["disconnected", "connected_unpaired", "error"].includes(deviceState.state) ? "splash" : !wizardComplete && ["bootloader", "needs_firmware", "needs_init"].includes(deviceState.state) ? "setup" - : ["ready", "needs_pin", "needs_passphrase"].includes(deviceState.state) ? "ready" + : deviceState.state === "ready" ? "ready" + : ["needs_pin", "needs_passphrase"].includes(deviceState.state) ? "splash" : "splash" // ── Overlays (render above everything, only one at a time) ────── @@ -480,7 +486,8 @@ function App() { connected={false} firmwareVersion={undefined} firmwareVerified={undefined} - onSettingsToggle={() => {}} + onSettingsToggle={() => setSettingsOpen((o) => !o)} + settingsOpen={settingsOpen} activeTab="vault" onTabChange={() => {}} watchOnly @@ -489,6 +496,14 @@ function App() { {}} /> + setSettingsOpen(false)} + deviceState={deviceState} + appVersion={appVersion} + onCheckForUpdate={update.checkForUpdate} + updatePhase={update.phase} + /> ) } @@ -556,7 +571,7 @@ function App() { > diff --git a/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx b/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx index a01a159..d825af1 100644 --- a/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx +++ b/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react" -import { Box, Text, Flex, Button, Input } from "@chakra-ui/react" +import { Box, Text, Flex, Button, Input, Spinner } from "@chakra-ui/react" import { useTranslation } from "react-i18next" interface PassphraseEntryProps { - onSubmit: (passphrase: string) => void + onSubmit: (passphrase: string) => Promise onCancel: () => void } @@ -16,17 +16,21 @@ export function PassphraseEntry({ onSubmit, onCancel }: PassphraseEntryProps) { const { t } = useTranslation("device") const [passphrase, setPassphrase] = useState("") const [showPassphrase, setShowPassphrase] = useState(false) + const [submitting, setSubmitting] = useState(false) const inputRef = useRef(null) // Auto-focus the input on mount useEffect(() => { - setTimeout(() => inputRef.current?.focus(), 100) - }, []) + if (!submitting) setTimeout(() => inputRef.current?.focus(), 100) + }, [submitting]) - const handleSubmit = useCallback(() => { - onSubmit(passphrase) - setPassphrase("") - }, [passphrase, onSubmit]) + const handleSubmit = useCallback(async () => { + if (submitting) return + setSubmitting(true) + await onSubmit(passphrase) + // Stay in "Confirm on device" state — the parent will unmount us + // when the device state transitions away from needs_passphrase. + }, [passphrase, onSubmit, submitting]) // Keyboard: Enter on input submits; Escape anywhere dismisses const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -38,11 +42,11 @@ export function PassphraseEntry({ onSubmit, onCancel }: PassphraseEntryProps) { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onCancel() + if (e.key === "Escape" && !submitting) onCancel() } window.addEventListener("keydown", onKeyDown) return () => window.removeEventListener("keydown", onKeyDown) - }, [onCancel]) + }, [onCancel, submitting]) return ( - - {t("passphrase.title")} - - - {t("passphrase.description")} - - - {/* Passphrase input */} - - + + {t("passphrase.confirmOnDevice")} + + + {t("passphrase.confirmOnDeviceHint")} + + + + ) : ( + /* ── Passphrase input state ── */ + <> + + {t("passphrase.title")} + + + {t("passphrase.description")} + + + {/* Passphrase input */} + + - - {t("passphrase.warning")} - + + {t("passphrase.warning")} + - {/* Action buttons */} - - - - + {/* Action buttons */} + + + + + + )} ) diff --git a/projects/keepkey-vault/src/mainview/i18n/index.ts b/projects/keepkey-vault/src/mainview/i18n/index.ts index 3a77ee0..e754162 100644 --- a/projects/keepkey-vault/src/mainview/i18n/index.ts +++ b/projects/keepkey-vault/src/mainview/i18n/index.ts @@ -32,6 +32,7 @@ i18n .init({ lng: savedLang, fallbackLng: "en", + partialBundledLanguages: true, defaultNS: "common", ns: [ "common", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json index d64cb27..2aa87e0 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json @@ -20,7 +20,9 @@ "description": "Ihr Gerät hat den Passphrase-Schutz aktiviert. Geben Sie Ihre BIP-39-Passphrase ein, um zu entsperren. Lassen Sie das Feld leer für die Standard-Wallet.", "placeholder": "Passphrase (optional)", "warning": "Passphrasen unterscheiden Groß- und Kleinschreibung. Eine andere Passphrase erzeugt eine völlig andere Wallet.", - "unlock": "Entsperren" + "unlock": "Entsperren", + "confirmOnDevice": "Auf deinem KeepKey bestätigen", + "confirmOnDeviceHint": "Überprüfe den Bildschirm deines Geräts und drücke die Taste zum Bestätigen." }, "pairing": { "wantsToConnect": "möchte sich mit Ihrem KeepKey verbinden", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json index b343540..67c7380 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json @@ -20,7 +20,9 @@ "description": "Your device has passphrase protection enabled. Enter your BIP-39 passphrase to unlock. Leave empty for the default wallet.", "placeholder": "Passphrase (optional)", "warning": "Passphrases are case-sensitive. A different passphrase generates a completely different wallet.", - "unlock": "Unlock" + "unlock": "Unlock", + "confirmOnDevice": "Confirm on Your KeepKey", + "confirmOnDeviceHint": "Check your device screen and press the button to confirm." }, "pairing": { "wantsToConnect": "wants to connect to your KeepKey", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json index a67b63a..c37228c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json @@ -20,7 +20,9 @@ "description": "Tu dispositivo tiene protección por contraseña habilitada. Ingresa tu contraseña BIP-39 para desbloquear. Déjalo vacío para la billetera predeterminada.", "placeholder": "Contraseña (opcional)", "warning": "Las contraseñas distinguen entre mayúsculas y minúsculas. Una contraseña diferente genera una billetera completamente diferente.", - "unlock": "Desbloquear" + "unlock": "Desbloquear", + "confirmOnDevice": "Confirmar en tu KeepKey", + "confirmOnDeviceHint": "Revisa la pantalla de tu dispositivo y presiona el botón para confirmar." }, "pairing": { "wantsToConnect": "quiere conectarse a tu KeepKey", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json index ee15b92..55691c0 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json @@ -20,7 +20,9 @@ "description": "Votre appareil a la protection par phrase secrète activée. Entrez votre phrase secrète BIP-39 pour déverrouiller. Laissez vide pour le portefeuille par défaut.", "placeholder": "Phrase secrète (facultatif)", "warning": "Les phrases secrètes sont sensibles à la casse. Une phrase secrète différente génère un portefeuille complètement différent.", - "unlock": "Déverrouiller" + "unlock": "Déverrouiller", + "confirmOnDevice": "Confirmer sur votre KeepKey", + "confirmOnDeviceHint": "Vérifiez l'écran de votre appareil et appuyez sur le bouton pour confirmer." }, "pairing": { "wantsToConnect": "veut se connecter à votre KeepKey", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json index fbfee0d..c5bac75 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json @@ -20,7 +20,9 @@ "description": "Il tuo dispositivo ha la protezione passphrase abilitata. Inserisci la tua passphrase BIP-39 per sbloccare. Lascia vuoto per il portafoglio predefinito.", "placeholder": "Passphrase (facoltativa)", "warning": "Le passphrase distinguono maiuscole e minuscole. Una passphrase diversa genera un portafoglio completamente diverso.", - "unlock": "Sblocca" + "unlock": "Sblocca", + "confirmOnDevice": "Conferma sul tuo KeepKey", + "confirmOnDeviceHint": "Controlla lo schermo del dispositivo e premi il pulsante per confermare." }, "pairing": { "wantsToConnect": "vuole connettersi al tuo KeepKey", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json index 60fc5b2..d196b81 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json @@ -20,7 +20,9 @@ "description": "お使いのデバイスはパスフレーズ保護が有効になっています。ロックを解除するにはBIP-39パスフレーズを入力してください。デフォルトのウォレットの場合は空のままにしてください。", "placeholder": "パスフレーズ(任意)", "warning": "パスフレーズは大文字と小文字を区別します。異なるパスフレーズは完全に異なるウォレットを生成します。", - "unlock": "ロック解除" + "unlock": "ロック解除", + "confirmOnDevice": "KeepKeyで確認", + "confirmOnDeviceHint": "デバイスの画面を確認し、ボタンを押して承認してください。" }, "pairing": { "wantsToConnect": "がKeepKeyに接続しようとしています", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json index 1662838..a8e5e8c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json @@ -20,7 +20,9 @@ "description": "기기에 비밀번호 구문 보호가 활성화되어 있습니다. 잠금 해제하려면 BIP-39 비밀번호 구문을 입력하세요. 기본 지갑을 사용하려면 비워두세요.", "placeholder": "비밀번호 구문 (선택사항)", "warning": "비밀번호 구문은 대소문자를 구분합니다. 다른 비밀번호 구문은 완전히 다른 지갑을 생성합니다.", - "unlock": "잠금 해제" + "unlock": "잠금 해제", + "confirmOnDevice": "KeepKey에서 확인", + "confirmOnDeviceHint": "기기 화면을 확인하고 버튼을 눌러 승인하세요." }, "pairing": { "wantsToConnect": "이(가) KeepKey에 연결하려고 합니다", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json index bb01c1c..2294536 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json @@ -20,7 +20,9 @@ "description": "Seu dispositivo tem proteção por frase-senha ativada. Insira sua frase-senha BIP-39 para desbloquear. Deixe vazio para a carteira padrão.", "placeholder": "Frase-senha (opcional)", "warning": "Frases-senha diferenciam maiúsculas de minúsculas. Uma frase-senha diferente gera uma carteira completamente diferente.", - "unlock": "Desbloquear" + "unlock": "Desbloquear", + "confirmOnDevice": "Confirmar no seu KeepKey", + "confirmOnDeviceHint": "Verifique a tela do seu dispositivo e pressione o botão para confirmar." }, "pairing": { "wantsToConnect": "quer se conectar ao seu KeepKey", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json index e320546..9743ada 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json @@ -20,7 +20,9 @@ "description": "На вашем устройстве включена защита кодовой фразой. Введите кодовую фразу BIP-39 для разблокировки. Оставьте пустым для кошелька по умолчанию.", "placeholder": "Кодовая фраза (необязательно)", "warning": "Кодовые фразы чувствительны к регистру. Другая кодовая фраза создаёт совершенно другой кошелёк.", - "unlock": "Разблокировать" + "unlock": "Разблокировать", + "confirmOnDevice": "Подтвердите на KeepKey", + "confirmOnDeviceHint": "Проверьте экран устройства и нажмите кнопку для подтверждения." }, "pairing": { "wantsToConnect": "хочет подключиться к вашему KeepKey", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json index 4113bce..593f648 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json @@ -20,7 +20,9 @@ "description": "您的设备已启用密码短语保护。输入您的 BIP-39 密码短语以解锁。留空则使用默认钱包。", "placeholder": "密码短语(可选)", "warning": "密码短语区分大小写。不同的密码短语会生成完全不同的钱包。", - "unlock": "解锁" + "unlock": "解锁", + "confirmOnDevice": "在 KeepKey 上确认", + "confirmOnDeviceHint": "请检查设备屏幕并按下按钮确认。" }, "pairing": { "wantsToConnect": "想要连接到您的 KeepKey", From 1608dc98e1d2da95044632363d0f230e7f5b6a58 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 8 Mar 2026 19:43:45 -0500 Subject: [PATCH 02/11] fix: merge Windows build fixes into v1.1.1 release - Rename build folder to _build (electrobun.config, collect-externals, build-windows-production.ps1) - Add cross-platform window drag to TopNav (useWindowDrag on Windows, CSS class on Mac) - Update Makefile verify/clean targets to match _build rename Co-Authored-By: Claude Opus 4.6 --- Makefile | 6 +++--- projects/keepkey-vault/electrobun.config.ts | 3 ++- projects/keepkey-vault/scripts/collect-externals.ts | 2 +- .../keepkey-vault/src/mainview/components/TopNav.tsx | 11 +++++++++++ scripts/build-windows-production.ps1 | 2 +- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index ee5b47a..75d2b74 100644 --- a/Makefile +++ b/Makefile @@ -96,7 +96,7 @@ dmg: echo "DMG ready: $$DMG_OUT" clean: modules-clean - cd $(PROJECT_DIR) && rm -rf dist node_modules build artifacts + cd $(PROJECT_DIR) && rm -rf dist node_modules build _build artifacts # --- Audit & SBOM --- @@ -118,8 +118,8 @@ sign-check: @security find-identity -v -p codesigning | grep "$$ELECTROBUN_DEVELOPER_ID" || echo "WARNING: Certificate not found in keychain" verify: - @APP=$$(find $(PROJECT_DIR)/build -name "*.app" -maxdepth 2 | head -1); \ - if [ -z "$$APP" ]; then echo "No .app bundle found in build/"; exit 1; fi; \ + @APP=$$(find $(PROJECT_DIR)/_build -name "*.app" -maxdepth 2 | head -1); \ + if [ -z "$$APP" ]; then echo "No .app bundle found in _build/"; exit 1; fi; \ echo "Verifying: $$APP"; \ echo "--- codesign ---"; \ codesign --verify --deep --strict "$$APP" && echo "codesign: PASS" || echo "codesign: FAIL"; \ diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index d2954be..6866fb6 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -8,6 +8,7 @@ export default { urlSchemes: ["keepkey"], }, build: { + buildFolder: "_build", bun: { // Mark native addons and protobuf-dependent packages as external // so Bun loads them at runtime instead of bundling them. @@ -29,7 +30,7 @@ export default { copy: { "dist/index.html": "views/mainview/index.html", "dist/assets": "views/mainview/assets", - "build/_ext_modules": "node_modules", + "_build/_ext_modules": "node_modules", }, mac: { bundleCEF: false, diff --git a/projects/keepkey-vault/scripts/collect-externals.ts b/projects/keepkey-vault/scripts/collect-externals.ts index 0af4e76..aa6c25d 100644 --- a/projects/keepkey-vault/scripts/collect-externals.ts +++ b/projects/keepkey-vault/scripts/collect-externals.ts @@ -23,7 +23,7 @@ const EXTERNALS = [ const projectRoot = join(import.meta.dir, '..') const nmSource = join(projectRoot, 'node_modules') -const nmDest = join(projectRoot, 'build', '_ext_modules') +const nmDest = join(projectRoot, '_build', '_ext_modules') // Resolve file: linked packages to their actual source directories. // Bun's file: resolution can leave broken stubs in node_modules (empty dir with only node_modules/). diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index 1fbb11b..7313db2 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -1,6 +1,9 @@ import { Flex, Text, Box, Image, IconButton, HStack } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { Z } from "../lib/z-index" +import { IS_WINDOWS } from "../lib/platform" +import { useWindowDrag } from "../hooks/useWindowDrag" +import { rpcRequest } from "../lib/rpc" import kkIcon from "../assets/icon.png" export type NavTab = "vault" | "shapeshift" | "apps" @@ -48,6 +51,7 @@ const GridIcon = () => ( /** Minimal nav bar for splash / setup phases. */ export function SplashNav() { + const windowDrag = useWindowDrag() return ( rpcRequest("windowMaximize") : undefined} > rpcRequest("windowMaximize") : undefined} > {/* Left: device icon + label */} diff --git a/scripts/build-windows-production.ps1 b/scripts/build-windows-production.ps1 index a328dbc..4b1dc65 100644 --- a/scripts/build-windows-production.ps1 +++ b/scripts/build-windows-production.ps1 @@ -80,7 +80,7 @@ if ($PSCommandPath) { } $RepoRoot = Split-Path -Path $ScriptDir -Parent $ProjectDir = Join-Path $RepoRoot "projects\keepkey-vault" -$BuildDir = Join-Path $ProjectDir "build\dev-win-x64\keepkey-vault-dev" +$BuildDir = Join-Path $ProjectDir "_build\dev-win-x64\keepkey-vault-dev" $ArtifactsDir = Join-Path $RepoRoot $OutputDir # Read version from package.json From 8cadbbf0d92b9dc1383689071102a2d77a0c9f30 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 9 Mar 2026 01:02:44 -0600 Subject: [PATCH 03/11] fix: use absolute path for DMG in Makefile dmg target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xcrun stapler staple fails with relative paths — it can't resolve the file. Using $(pwd) ensures all tools (hdiutil, codesign, stapler) get a consistent absolute path. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ee5b47a..f9a1c8a 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ dmg: echo "Verifying extracted app..."; \ codesign --verify --deep --strict "$$APP" || (echo "ERROR: codesign verification failed"; exit 1); \ ln -s /Applications "$$STAGING/Applications"; \ - DMG_OUT="$(PROJECT_DIR)/artifacts/$(DMG_NAME)"; \ + DMG_OUT="$$(pwd)/$(PROJECT_DIR)/artifacts/$(DMG_NAME)"; \ rm -f "$$DMG_OUT"; \ echo "Creating DMG..."; \ hdiutil create -volname "KeepKey Vault" -srcfolder "$$STAGING" -ov -format UDZO "$$DMG_OUT"; \ From b30f421a51a0b0819cb5e06f66268512b4d2757d Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Mon, 9 Mar 2026 02:06:14 -0500 Subject: [PATCH 04/11] chore: bump electrobun.config version to 1.1.2 Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/electrobun.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index 6866fb6..a04dce2 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -4,7 +4,7 @@ export default { app: { name: "keepkey-vault", identifier: "com.keepkey.vault", - version: "1.1.1", + version: "1.1.2", urlSchemes: ["keepkey"], }, build: { From 9052d1203d1ca77e2fdd5a4646ad7c9d6731ca24 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 9 Mar 2026 13:00:59 -0600 Subject: [PATCH 05/11] fix: address code review findings for v1.1.2 - engine-controller: updatePhase = 'idle' (not null) on reboot poll timeout, emit disconnected state so UI recovers; reduce poll log frequency to every 30s - reports: add safeRoundSats() guard for satoshi values near MAX_SAFE_INTEGER, validate Pioneer API response shapes, export SECTION_TITLES constants - tax-export: use shared SECTION_TITLES constants, add row bounds checking to prevent silent data corruption on schema changes - PassphraseEntry: catch onSubmit errors to reset spinner (device disconnect during confirm no longer leaves overlay stuck) - App: suppress PIN auto-show during all firmware phases (not just rebooting) - index: post-decode firmware size validation (7.5MB binary limit) --- .../src/bun/engine-controller.ts | 5 ++- projects/keepkey-vault/src/bun/index.ts | 2 + projects/keepkey-vault/src/bun/reports.ts | 41 +++++++++++++++---- projects/keepkey-vault/src/bun/tax-export.ts | 7 +++- projects/keepkey-vault/src/mainview/App.tsx | 4 +- .../components/device/PassphraseEntry.tsx | 7 +++- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index 1a28635..a752553 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -429,11 +429,12 @@ export class EngineController extends EventEmitter { this.rebootPollCount++ if (this.rebootPollCount >= EngineController.MAX_REBOOT_POLLS) { console.warn('[Engine] Reboot poll: max attempts reached (5 min), stopping') - this.updatePhase = null + this.updatePhase = 'idle' this.stopRebootPoll() + this.updateState('disconnected') return } - console.log(`[Engine] Reboot poll ${this.rebootPollCount}/${EngineController.MAX_REBOOT_POLLS}: calling syncState()`) + if (this.rebootPollCount % 6 === 0) console.log(`[Engine] Reboot poll ${this.rebootPollCount}/${EngineController.MAX_REBOOT_POLLS}: calling syncState()`) this.syncState() }, 5000) } diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 9b6cd8a..fa1fce4 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -250,11 +250,13 @@ const rpc = BrowserView.defineRPC({ analyzeFirmware: async (params) => { if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)') const buf = Buffer.from(params.data, 'base64') + if (buf.length > 7_500_000) throw new Error('Decoded firmware exceeds 7.5MB limit') return engine.analyzeFirmware(buf) }, flashCustomFirmware: async (params) => { if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)') const buf = Buffer.from(params.data, 'base64') + if (buf.length > 7_500_000) throw new Error('Decoded firmware exceeds 7.5MB limit') await engine.flashCustomFirmware(buf) }, resetDevice: async (params) => { await engine.resetDevice(params) }, diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index a0161c6..11d9686 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -17,6 +17,24 @@ import { getPioneerApiBase } from './pioneer' const REPORT_TIMEOUT_MS = 60_000 +/** Section title prefixes — shared with tax-export.ts for reliable extraction. */ +export const SECTION_TITLES = { + TX_DETAILS: 'Transaction Details', + TX_HISTORY: 'Transaction History', +} as const + +/** Safely round a satoshi string/number to integer, guarding against values beyond Number.MAX_SAFE_INTEGER. */ +function safeRoundSats(value: unknown): number { + if (value === undefined || value === null) return 0 + const n = Number(value) + if (!Number.isFinite(n)) return 0 + if (Math.abs(n) > Number.MAX_SAFE_INTEGER) { + console.warn(`[Report] safeRoundSats: value ${value} exceeds MAX_SAFE_INTEGER, clamping`) + return n > 0 ? Number.MAX_SAFE_INTEGER : -Number.MAX_SAFE_INTEGER + } + return Math.round(n) +} + function getPioneerQueryKey(): string { return process.env.PIONEER_API_KEY || `key:public-${Date.now()}` } @@ -45,7 +63,12 @@ async function fetchPubkeyInfo(baseUrl: string, xpub: string): Promise { ) if (!resp.ok) throw new Error(`PubkeyInfo ${resp.status}`) const json = await resp.json() - return json.data || json + const result = json.data || json + if (typeof result !== 'object' || result === null) { + console.warn('[Report] fetchPubkeyInfo: unexpected response shape, returning empty object') + return {} + } + return result } async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Promise { @@ -60,6 +83,10 @@ async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Prom ) if (!resp.ok) throw new Error(`TxHistory ${resp.status}`) const json = await resp.json() + if (typeof json !== 'object' || json === null) { + console.warn('[Report] fetchTxHistory: unexpected response shape, returning empty array') + return [] + } const histories = json.histories || json.data?.histories || [] return histories[0]?.transactions || [] } @@ -257,9 +284,9 @@ async function buildBtcSections( xpub: x.xpub, scriptType: x.scriptType, label: `${x.scriptType}`, - balance: Math.round(Number(info.balance || 0)), - totalReceived: Math.round(Number(info.totalReceived || 0)), - totalSent: Math.round(Number(info.totalSent || 0)), + balance: safeRoundSats(info.balance), + totalReceived: safeRoundSats(info.totalReceived), + totalSent: safeRoundSats(info.totalSent), txCount: info.txs || 0, usedAddresses: used.map((t: any) => ({ name: t.name, path: t.path, transfers: t.transfers, @@ -380,7 +407,7 @@ async function buildBtcSections( }) sections.push({ - title: `Transaction History (${allTxs.length})`, + title: `${SECTION_TITLES.TX_HISTORY} (${allTxs.length})`, type: 'table', data: { headers: ['#', 'Dir', 'TXID', 'Block', 'Date', 'Value (BTC)', 'Fee (BTC)'], @@ -412,7 +439,7 @@ async function buildBtcSections( const detailTxs = allTxs.slice(0, MAX_TX_DETAILS) if (detailTxs.length > 0) { sections.push({ - title: `Transaction Details (${Math.min(allTxs.length, MAX_TX_DETAILS)}${allTxs.length > MAX_TX_DETAILS ? ` of ${allTxs.length}` : ''})`, + title: `${SECTION_TITLES.TX_DETAILS} (${Math.min(allTxs.length, MAX_TX_DETAILS)}${allTxs.length > MAX_TX_DETAILS ? ` of ${allTxs.length}` : ''})`, type: 'table', data: { headers: ['TXID', 'Dir', 'Block', 'Date', 'Value (BTC)', 'Fee (BTC)', 'From', 'To'], @@ -440,7 +467,7 @@ async function buildBtcSections( sections.push({ title: 'Note', type: 'text', data: `Showing ${MAX_TX_DETAILS} of ${allTxs.length} transactions.` }) } } else { - sections.push({ title: 'Transaction History', type: 'text', data: 'No transactions found' }) + sections.push({ title: SECTION_TITLES.TX_HISTORY, type: 'text', data: 'No transactions found' }) } // 3. Address flow analysis from tx data diff --git a/projects/keepkey-vault/src/bun/tax-export.ts b/projects/keepkey-vault/src/bun/tax-export.ts index ae5e7bd..0f29b8c 100644 --- a/projects/keepkey-vault/src/bun/tax-export.ts +++ b/projects/keepkey-vault/src/bun/tax-export.ts @@ -10,6 +10,7 @@ */ import type { ReportData, ReportSection } from '../shared/types' +import { SECTION_TITLES } from './reports' // ── Internal canonical transaction model ──────────────────────────── @@ -52,14 +53,14 @@ export function extractTransactionsFromReport(data: ReportData): TaxTransaction[ // Find the detailed transaction table first (has From/To) let txSection = data.sections.find( - s => s.type === 'table' && s.title.startsWith('Transaction Details'), + s => s.type === 'table' && s.title.startsWith(SECTION_TITLES.TX_DETAILS), ) let useDetailed = true // Fallback to Transaction History (less detail) if (!txSection) { txSection = data.sections.find( - s => s.type === 'table' && s.title.startsWith('Transaction History'), + s => s.type === 'table' && s.title.startsWith(SECTION_TITLES.TX_HISTORY), ) useDetailed = false } @@ -72,6 +73,7 @@ export function extractTransactionsFromReport(data: ReportData): TaxTransaction[ for (const row of rows) { if (useDetailed) { // Transaction Details: [TXID, Dir, Block, Date, Value (BTC), Fee (BTC), From, To] + if (row.length < 8) { console.warn(`[TaxExport] Skipping row with ${row.length} cols, expected 8`); continue } const txid = String(row[0] || '') const dir = String(row[1] || '').toLowerCase() const date = String(row[3] || '') @@ -83,6 +85,7 @@ export function extractTransactionsFromReport(data: ReportData): TaxTransaction[ txs.push(buildBtcTx(txid, dir, date, value, fee, from, to)) } else { // Transaction History: [#, Dir, TXID, Block, Date, Value (BTC), Fee (BTC)] + if (row.length < 7) { console.warn(`[TaxExport] Skipping row with ${row.length} cols, expected 7`); continue } const txid = String(row[2] || '') const dir = String(row[1] || '').toLowerCase() const date = String(row[4] || '') diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index c99481b..bc39deb 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -258,9 +258,9 @@ function App() { }, []) // Auto-show PIN for locked device (only once — respect user dismiss) - // M3 fix: skip auto-show during reboot phase — backend promptPin handles it with a delay + // Skip auto-show during any firmware operation phase — backend promptPin handles it with a delay useEffect(() => { - if (deviceState.state === "needs_pin" && !pinRequestType && !pinDismissed && deviceState.updatePhase !== "rebooting") { + if (deviceState.state === "needs_pin" && !pinRequestType && !pinDismissed && (!deviceState.updatePhase || deviceState.updatePhase === "idle")) { setPinRequestType("current") } }, [deviceState.state, deviceState.updatePhase, pinRequestType, pinDismissed]) diff --git a/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx b/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx index d825af1..b137f69 100644 --- a/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx +++ b/projects/keepkey-vault/src/mainview/components/device/PassphraseEntry.tsx @@ -27,7 +27,12 @@ export function PassphraseEntry({ onSubmit, onCancel }: PassphraseEntryProps) { const handleSubmit = useCallback(async () => { if (submitting) return setSubmitting(true) - await onSubmit(passphrase) + try { + await onSubmit(passphrase) + } catch { + // If sendPassphrase fails (e.g. device disconnected), reset so user can retry or cancel + setSubmitting(false) + } // Stay in "Confirm on device" state — the parent will unmount us // when the device state transitions away from needs_passphrase. }, [passphrase, onSubmit, submitting]) From fc58084a4eb06cf8e0156a3809241207296115b9 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 10 Mar 2026 19:25:21 -0600 Subject: [PATCH 06/11] feat: add cross-chain swap support via THORChain + Pioneer API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SwapDialog with quote, review, sign, broadcast flow - SwapTracker floating bubble + SwapHistoryDialog for tracking - swap-tracker polls Pioneer GetPendingSwap per-txHash (avoids server route conflict with GetAllPendingSwaps) - swap-parsing for THORChain asset/CAIP conversion - Fail-fast: app crashes on startup if Pioneer SDK missing required methods - UI shows 'submitted' (not 'complete') after broadcast — only complete when outbound confirmed - EVM tx builder + cosmos tx builder swap memo support - RPC schema + types for swap operations - i18n swap translations --- modules/hdwallet | 2 +- projects/keepkey-vault/electrobun.config.ts | 1 + projects/keepkey-vault/package.json | 2 +- .../scripts/collect-externals.ts | 49 +- projects/keepkey-vault/src/bun/evm-rpc.ts | 32 + projects/keepkey-vault/src/bun/index.ts | 151 ++- projects/keepkey-vault/src/bun/reports.ts | 89 +- .../keepkey-vault/src/bun/swap-parsing.ts | 189 +++ .../keepkey-vault/src/bun/swap-tracker.ts | 348 ++++++ projects/keepkey-vault/src/bun/swap.ts | 534 +++++++++ .../keepkey-vault/src/bun/txbuilder/cosmos.ts | 60 +- .../keepkey-vault/src/bun/txbuilder/evm.ts | 45 +- .../keepkey-vault/src/bun/txbuilder/index.ts | 1 + projects/keepkey-vault/src/mainview/App.tsx | 2 + .../src/mainview/components/AssetPage.tsx | 20 +- .../src/mainview/components/SwapDialog.tsx | 1021 +++++++++++++++++ .../mainview/components/SwapHistoryDialog.tsx | 400 +++++++ .../src/mainview/components/SwapTracker.tsx | 153 +++ .../keepkey-vault/src/mainview/i18n/index.ts | 3 + .../src/mainview/i18n/locales/en/asset.json | 1 + .../src/mainview/i18n/locales/en/nav.json | 1 + .../src/mainview/i18n/locales/en/swap.json | 85 ++ .../keepkey-vault/src/shared/rpc-schema.ts | 11 +- projects/keepkey-vault/src/shared/types.ts | 112 ++ 24 files changed, 3199 insertions(+), 113 deletions(-) create mode 100644 projects/keepkey-vault/src/bun/swap-parsing.ts create mode 100644 projects/keepkey-vault/src/bun/swap-tracker.ts create mode 100644 projects/keepkey-vault/src/bun/swap.ts create mode 100644 projects/keepkey-vault/src/mainview/components/SwapDialog.tsx create mode 100644 projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx create mode 100644 projects/keepkey-vault/src/mainview/components/SwapTracker.tsx create mode 100644 projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json diff --git a/modules/hdwallet b/modules/hdwallet index 15b6b23..87e594b 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 15b6b23e4dd96b780b71a2382dde30bc369968ab +Subproject commit 87e594b7845f779c344c535bb67ef323d88cd1bf 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 2b05bc9..357e3b6 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -31,12 +31,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 aa6c25d..429d5a0 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/evm-rpc.ts b/projects/keepkey-vault/src/bun/evm-rpc.ts index 200308b..f16e758 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() + // Fallback 0x12 = 18 decimal (the standard ERC-20 default) + return Number(BigInt(result || '0x12')) +} + // ── Direct RPC methods for custom chains ───────────────────────────── export async function getEvmBalance(rpcUrl: string, address: string): Promise { @@ -87,6 +104,21 @@ 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]) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index fa1fce4..166e2c3 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -31,9 +31,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 @@ -74,22 +71,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) @@ -237,6 +234,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 @@ -1367,6 +1368,121 @@ 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}` + 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 + const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}` + const cachedQuote = swapQuoteCache.get(cacheKey) + 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) + }, + // ── Balance cache (instant portfolio) ──────────────────── getCachedBalances: async () => { const deviceId = engine.getDeviceState().deviceId @@ -1444,6 +1560,23 @@ 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] FATAL: Failed to initialize swap tracker:', e) + process.exit(1) +}) + // 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..5459d76 --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -0,0 +1,189 @@ +/** + * 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 + const inboundAddress = quote.inbound_address || raw.inbound_address || txParams.vaultAddress + // Expiry for depositWithExpiry + const expiry = raw.expiry || quote.expiry || 0 + + if (!inboundAddress) 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 from raw THORNode data + const estimatedTime = raw.inbound_confirmation_seconds || raw.total_swap_seconds + || quote.totalSwapSeconds || quote.estimatedTime || 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-tracker.ts b/projects/keepkey-vault/src/bun/swap-tracker.ts new file mode 100644 index 0000000..ed36d5f --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -0,0 +1,348 @@ +/** + * 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 } from '../shared/types' +import { getPioneer } from './pioneer' +import { assetToCaip } from './swap-parsing' + +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(', ')})`) +} + +/** Register a newly broadcast swap for tracking */ +export function trackSwap( + result: SwapResult, + params: ExecuteSwapParams, + quote: SwapQuote, +): void { + 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: Date.now(), + updatedAt: Date.now(), + estimatedTime: quote.estimatedTime, + } + + pendingSwaps.set(result.txid, swap) + console.log(`${TAG} Tracking swap: ${result.txid} (${swap.fromSymbol} → ${swap.toSymbol})`) + + // 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) + if (pendingSwaps.size === 0) { + 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 + schedulePoll() + // Poll immediately on start + pollAllSwaps() +} + +/** Schedule next poll with adaptive interval */ +function schedulePoll(): void { + if (pollTimer) clearInterval(pollTimer) + const interval = getPollInterval() + console.log(`${TAG} Next poll in ${interval / 1000}s`) + pollTimer = setInterval(async () => { + await pollAllSwaps() + // Re-schedule if interval should change (swap aged into next phase) + const newInterval = getPollInterval() + if (newInterval !== interval) { + schedulePoll() + } + }, interval) +} + +function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + console.log(`${TAG} Stopped polling (no active swaps)`) + } +} + +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}`) + + 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) + + // Check for time estimation data from Pioneer + 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 + + // Update estimated time if Pioneer has better data + if (timeEstimate?.total_swap_seconds && timeEstimate.total_swap_seconds > 0) { + swap.estimatedTime = timeEstimate.total_swap_seconds + } + + // Update expected output if Pioneer reports actual amount + if (remoteSwap.buyAsset?.amount && parseFloat(remoteSwap.buyAsset.amount) > 0) { + swap.expectedOutput = remoteSwap.buyAsset.amount + } + + console.log(`${TAG} Status change: ${swap.txid} → ${newStatus} (confirmations=${confirmations}, outbound=${outboundConfirmations || 0}/${outboundRequiredConfirmations || '?'})`) + pushUpdate(swap) + + if (newStatus === 'completed' || newStatus === 'failed' || newStatus === 'refunded') { + pushComplete(swap) + } + } + } 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..8f9a294 --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -0,0 +1,534 @@ +/** + * 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 } 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, parseThorAsset, assetToCaip, THOR_TO_CHAIN } 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' + +/** 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 by chain family — UTXO OP_RETURN is 80 bytes, others are generous */ +const MEMO_LIMITS: Record = { + utxo: 80, + evm: 1000, + cosmos: 512, + xrp: 512, +} + +// ── Pool/Asset fetching via Pioneer ───────────────────────────────── + +let assetCache: SwapAsset[] = [] +let assetCacheTime = 0 +const ASSET_CACHE_TTL = 5 * 60_000 // 5 minutes + +/** 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 + + console.log(`${TAG} Fetching quote: ${params.fromAsset} → ${params.toAsset} (${params.amount})`) + console.log(`${TAG} CAIP: ${sellCaip} → ${buyCaip}`) + + const quoteResp = await pioneer.Quote({ + sellAsset: sellCaip, + sellAmount: params.amount, // Pioneer expects DECIMAL format (human-readable) + buyAsset: buyCaip, + recipientAddress: params.toAddress, + senderAddress: params.fromAddress, + slippage, + }) + + 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 ────────────────────────────────────────────────── + +/** Dependencies injected by the caller (index.ts) to avoid circular imports */ +export interface SwapContext { + wallet: any + 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) + if (params.memo && fromChain.chainFamily !== 'evm' && !params.memo.toLowerCase().includes(toAddress.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 memoLimit = MEMO_LIMITS[fromChain.chainFamily] || 512 + if (params.memo.length > memoLimit) { + throw new Error(`Swap memo too long for ${fromChain.chainFamily} (${params.memo.length} chars, max ${memoLimit})`) + } + + 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') { + let xpub: string | undefined = getBtcXpub() + 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: ${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 && params.feeLevel <= 2) gasPrice = gasPrice * 80n / 100n + else if (params.feeLevel && params.feeLevel >= 8) gasPrice = gasPrice * 150n / 100n + + let nonce = 0 + if (rpcUrl) { + try { nonce = await getEvmNonce(rpcUrl, fromAddress) } catch (e: any) { + console.warn(`${TAG} Failed to fetch nonce via RPC: ${e.message}`) + } + } else { + try { + const nd = await pioneer.GetNonceByNetwork({ networkId: fromChain.networkId, address: fromAddress }) + nonce = nd?.data?.nonce ?? 0 + } catch (e: any) { + console.warn(`${TAG} Failed to fetch nonce via Pioneer: ${e.message}`) + } + } + + 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 ~${Number(totalGas) / 1e18}, ` + + `have ${Number(nativeBalance) / 1e18}` + ) + } + + // 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}`) + } 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}`) + } + + // NONCE ORDERING: Approval=nonce N, deposit=nonce N+1 — deposit can't mine before approval. + // RISK: If approval reverts, the deposit tx stays pending forever (nonce gap). + // RECOVERY: User must send a 0-value tx to themselves with nonce N to consume the gap, + // or the deposit will eventually be dropped from the mempool. + console.warn(`${TAG} Approval broadcast (nonce=${nonce}) — deposit will use nonce=${nonce + 1}. ` + + `If approval fails, send a 0-value self-tx with nonce ${nonce} to unstick.`) + 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, + ) + + const unsignedTx = { + chainId, + addressNList: fromChain.defaultPath, + nonce: toHex(nonce), + gasLimit: toHex(depositGasLimit), + 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 gasLimit = DEPOSIT_GAS_LIMITS[fromChain.id] || 120000n + const gasFee = gasPrice * gasLimit + + if (nativeBalance < amountWei + gasFee) { + throw new Error( + `Insufficient ${fromChain.symbol}: need ${Number(amountWei + gasFee) / 1e18}, ` + + `have ${Number(nativeBalance) / 1e18}` + ) + } + + const data = encodeDepositWithExpiry( + params.inboundAddress, // vault address + ZERO_ADDRESS, // native asset (not ERC-20) + amountWei, + params.memo, + expiry, + ) + + 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..2f4fec9 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' }] }, @@ -46,6 +58,7 @@ export interface BuildCosmosParams { amount: string // human-readable (e.g. "1.5") memo?: string isMax?: boolean + isSwapDeposit?: boolean // use MsgDeposit instead of MsgSend (for THORChain/Maya swaps) fromAddress: string } @@ -54,7 +67,7 @@ export async function buildCosmosTx( chain: ChainDef, params: BuildCosmosParams, ) { - const { to, memo = '', isMax = false, fromAddress } = params + const { to, memo = '', isMax = false, isSwapDeposit = false, fromAddress } = params const denom = chain.denom || chain.symbol.toLowerCase() @@ -97,28 +110,47 @@ export async function buildCosmosTx( // 3. Build unsigned tx const fee = FEE_TEMPLATES[chain.id] || FEE_TEMPLATES.cosmos - const msgType = MSG_TYPES[chain.id] || 'cosmos-sdk/MsgSend' 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 e02f5f2..f30efdd 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/index.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/index.ts @@ -62,6 +62,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/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index bc39deb..726b1e5 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" @@ -614,6 +615,7 @@ function App() { wcUri={wcUri} onClose={handleCloseWalletConnect} /> + {/* 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/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 89cd20a..74cbaa2 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -1,7 +1,7 @@ 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 { BTC_SCRIPT_TYPES, btcAccountPath } from "../../shared/chains" @@ -11,6 +11,7 @@ import { AnimatedUsd } from "./AnimatedUsd" import { formatBalance, formatUsd } from "../lib/formatting" import { ReceiveView } from "./ReceiveView" import { SendForm } from "./SendForm" +import { SwapDialog } from "./SwapDialog" import { BtcXpubSelector } from "./BtcXpubSelector" import { EvmAddressSelector } from "./EvmAddressSelector" import { useBtcAccounts } from "../hooks/useBtcAccounts" @@ -194,6 +195,7 @@ export function AssetPage({ chain, balance, onBack }: AssetPageProps) { 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 @@ -219,9 +221,10 @@ export function AssetPage({ chain, balance, onBack }: AssetPageProps) { } }, []) - 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 }, + { id: "swap", label: t("swap"), icon: FaExchangeAlt }, ] // Shared token row renderer @@ -421,7 +424,10 @@ export function AssetPage({ chain, balance, onBack }: AssetPageProps) { 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" @@ -504,6 +510,14 @@ export function AssetPage({ chain, balance, onBack }: AssetPageProps) { evmAddressIndex={isEvm ? evmAddresses.selectedIndex : undefined} /> )} + {/* SwapDialog rendered as overlay */} + setShowSwapDialog(false)} + chain={chain} + balance={balance} + address={address} + /> {/* Tokens Section — with spam filter */} 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..0b51c02 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -0,0 +1,1021 @@ +/** + * 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 } from "../lib/rpc" +import { formatBalance } from "../lib/formatting" +import { getAssetIcon } from "../../shared/assetLookup" +import { CHAINS } from "../../shared/chains" +import type { ChainDef } from "../../shared/chains" +import type { SwapAsset, SwapQuote, ChainBalance } 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', + cosmos: 'ETH.ETH', + thorchain: 'ETH.ETH', + avalanche: 'ETH.ETH', + bsc: 'ETH.ETH', + base: 'ETH.ETH', +} + +// ── Icons ─────────────────────────────────────────────────────────── +const SwapArrowIcon = () => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + +const SearchIcon = () => ( + + + + +) + +const ThorchainIcon = ({ size = 14 }: { size?: number }) => ( + + + + +) + +const CheckIcon = () => ( + + + +) + +const ShieldIcon = () => ( + + + +) + +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); } + } +` + +// ── 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 [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): string | 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 ? token.balance : null + } + return chain.balance + }, [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} + + + + + + {filtered.length === 0 ? ( + {t("noAssets")} + ) : ( + filtered.map((asset) => { + const bal = getBalance(asset) + return ( + { onSelect(asset); setOpen(false); setSearch("") }} + > + { (e.target as HTMLImageElement).style.display = 'none' }} + /> + + {asset.symbol} + {asset.name} + + {bal && ( + {formatBalance(bal)} + )} + + ) + }) + )} + + + + ) + } + + return ( + + {label} + { if (!disabled) setOpen(true) }} + > + {selected ? ( + <> + { (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 +} + +// ── Main SwapDialog ───────────────────────────────────────────────── +export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialogProps) { + const { t } = useTranslation("swap") + + // ── 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 [isMax, setIsMax] = useState(false) + + const [quote, setQuote] = useState(null) + const [error, setError] = useState(null) + const [txid, setTxid] = useState(null) + const [copied, setCopied] = useState(false) + + // ── 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]) + + // ── 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]) + + 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("") + 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) + + 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]) + + // ── Reset ───────────────────────────────────────────────────────── + const reset = useCallback(() => { + setPhase('input') + setFromAsset(null) + setToAsset(null) + setAmount("") + setIsMax(false) + setQuote(null) + setError(null) + setTxid(null) + hasAutoSelected.current = false + }, []) + + 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 && !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 — swap broadcast, awaiting confirmations ─ */} + {phase === 'submitted' && txid && fromAsset && toAsset && ( + + {/* Pulsing broadcast indicator — NOT a checkmark */} + + + + + + + + + {t("swapSubmitted")} + {t("waitingForConfirmations")} + {t("swapSubmittedDesc")} + + + {/* ETA */} + {quote?.estimatedTime && quote.estimatedTime > 0 && ( + + + {t("estimatedTime")}: {formatTime(quote.estimatedTime)} + + + )} + + {/* Amount summary */} + + + + + {displayAmount} {fromAsset.symbol} + + + + + + + ~{quote?.expectedOutput} {toAsset.symbol} + + + + + {/* Txid */} + + + + {t("txid")} + + {txid.slice(0, 12)}...{txid.slice(-8)} + + + + + + + {t("trackingSwap")} + + {/* Actions */} + + + + + + )} + + {/* ── SIGNING / APPROVING / BROADCASTING ───────────────── */} + {busy && fromAsset && toAsset && ( + + {/* Device icon with pulse */} + + + + + + + + + + {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} + + + )} + + {/* ── REVIEW ───────────────────────────────────────────── */} + {phase === 'review' && quote && fromAsset && toAsset && !busy && ( + + {/* You Send / You Receive */} + + {t("youSend")} + + + + {displayAmount} {fromAsset.symbol} + {fromAsset.name} + + + + + + + + + + + + + + {t("youReceive")} + + + + ~{quote.expectedOutput} {toAsset.symbol} + {toAsset.name} + + + + + {/* Quote details */} + + + + {t("rate")} + + 1 {fromAsset.symbol} = {formatBalance( + (parseFloat(quote.expectedOutput) / parseFloat(displayAmount || '1')).toString() + )} {toAsset.symbol} + + + + {t("minimumReceived")} + + {formatBalance(quote.minimumOutput)} {toAsset.symbol} + + + + {t("networkFee")} + + {formatBalance(quote.fees.outbound)} ({quote.fees.totalBps / 100}%) + + + + {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'} + + + {fromAddress && ( + + {fromAddress.slice(0, 8)}...{fromAddress.slice(-6)} + + )} + + )} + + {fromAsset && ( + + + )} + + {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} + + + )} + {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..cebad0b --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx @@ -0,0 +1,400 @@ +/** + * SwapHistoryDialog — Full dialog for viewing active + historical swaps. + * + * Opened from the SwapTracker floating bubble. + * Shows active swaps at top, completed/failed below. + */ +import { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { useTranslation } from "react-i18next" +import { Box, Flex, Text, VStack, HStack, Button } from "@chakra-ui/react" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { Z } from "../lib/z-index" +import type { PendingSwap, SwapStatusUpdate } from "../../shared/types" + +// ── 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` +} + +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); } + } +` + +// ── 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 && ( + + )} + + ) + })} + + ) +} + +// ── Swap card ─────────────────────────────────────────────────────── + +function SwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (txid: string) => 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 ( + + {/* Header: asset pair + status */} + + + + {swap.fromSymbol} + + + + {swap.toSymbol} + + + + {statusLabel} + + + + {/* Amounts */} + + {swap.fromAmount} {swap.fromSymbol} → ~{swap.expectedOutput} {swap.toSymbol} + + + {/* Stage indicator */} + + + {/* Stage labels */} + + = 1 ? color : 'kk.textMuted'}>{t("stageInput")} + = 2 ? color : 'kk.textMuted'}>{t("stageProtocol")} + = 3 ? color : 'kk.textMuted'}>{t("stageOutput")} + + + {/* Confirmations */} + {swap.status === 'confirming' && swap.confirmations > 0 && ( + + {swap.confirmations} {t("confirmations")} + + )} + + {/* Output confirmations progress */} + {swap.outboundConfirmations !== undefined && swap.outboundRequiredConfirmations !== undefined && ( + + + {t("outputConfirmations")} + + {swap.outboundConfirmations} / {swap.outboundRequiredConfirmations} + + + + + + + )} + + {/* Error message */} + {swap.error && ( + {swap.error} + )} + + {/* Footer */} + + + {t("elapsed")}: {formatElapsed(elapsed)} + {!isFinal && swap.estimatedTime > 0 && ` / ${t("estimated")} ${formatElapsed(swap.estimatedTime * 1000)}`} + + + + {isFinal && ( + + )} + + + + ) +} + +// ── Main SwapHistoryDialog ────────────────────────────────────────── + +interface SwapHistoryDialogProps { + open: boolean + onClose: () => void +} + +export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { + const { t } = useTranslation("swap") + const [swaps, setSwaps] = useState([]) + + const fetchSwaps = useCallback(() => { + rpcRequest('getPendingSwaps', undefined, 5000) + .then((result) => { + if (result) setSwaps(result) + }) + .catch(() => {}) + }, []) + + // Fetch on open + useEffect(() => { + if (open) fetchSwaps() + }, [open, fetchSwaps]) + + // Listen for DOM events 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 + }) + }) + + return () => { unsub1(); unsub2() } + }, [fetchSwaps]) + + // Poll while open and there are active swaps + const activeSwaps = useMemo(() => + swaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), + [swaps] + ) + + const completedSwaps = useMemo(() => + swaps.filter(s => s.status === 'completed' || s.status === 'failed' || s.status === 'refunded'), + [swaps] + ) + + useEffect(() => { + if (!open || activeSwaps.length === 0) return + const interval = setInterval(fetchSwaps, 15000) + return () => clearInterval(interval) + }, [open, activeSwaps.length, fetchSwaps]) + + const handleDismiss = useCallback((txid: string) => { + rpcRequest('dismissSwap', { txid }).catch(() => {}) + setSwaps(prev => prev.filter(s => s.txid !== txid)) + }, []) + + if (!open) return null + + return ( + + + + 0 ? 'rgba(35,220,200,0.3)' : 'kk.border'} + borderRadius="xl" + w="520px" + maxW="90vw" + maxH="80vh" + display="flex" + flexDirection="column" + overflow="hidden" + onClick={(e) => e.stopPropagation()} + style={{ animation: 'kkHistoryFadeIn 0.2s ease-out' }} + > + {/* Header */} + + + {t("swapHistory")} + {swaps.length > 0 && ( + + {swaps.length} + + )} + + + + + {/* Body */} + + {swaps.length === 0 ? ( + + + {t("noSwapHistory")} + + ) : ( + + {/* Active swaps */} + {activeSwaps.length > 0 && ( + <> + + + + {t("activeSwaps")} ({activeSwaps.length}) + + + {activeSwaps.map(swap => ( + + ))} + + )} + + {/* Completed swaps */} + {completedSwaps.length > 0 && ( + <> + {activeSwaps.length > 0 && } + + {t("completedSwaps")} ({completedSwaps.length}) + + {completedSwaps.map(swap => ( + + ))} + + )} + + )} + + + + ) +} 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..9d059fb --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx @@ -0,0 +1,153 @@ +/** + * 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 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 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 + }) + }) + + 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) + } + + // 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)} /> + + ) +} 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 410782e..cfc86b1 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/swap.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json new file mode 100644 index 0000000..ffd1d0a --- /dev/null +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json @@ -0,0 +1,85 @@ +{ + "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" +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 8477f71..e0a9978 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 } from './types' /** * RPC Schema for Bun ↔ WebView communication. @@ -126,6 +126,13 @@ 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 } + // ── Balance cache (instant portfolio) ───────────────────────────── getCachedBalances: { params: void; response: { balances: ChainBalance[]; updatedAt: number } | null } @@ -171,6 +178,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 } } webview: { diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index cef30af..ecf02ea 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 @@ -362,6 +363,117 @@ 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 +} + // 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. From dfc9f4515e0805b9cf556b5849f616c5ecdd458c Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 10 Mar 2026 20:50:57 -0600 Subject: [PATCH 07/11] feat: enhance swap UI, tracking, and multi-chain tx builder support Expand SwapDialog with improved quote display, fee breakdown, and affiliate fee handling. Add swap history persistence to SQLite with detailed status tracking and reporting. Improve tx builders for cosmos, UTXO, and XRP chains. Add swap-report module and EVM RPC gas estimation support. Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/db.ts | 234 ++++++- projects/keepkey-vault/src/bun/evm-rpc.ts | 23 + projects/keepkey-vault/src/bun/index.ts | 40 +- .../keepkey-vault/src/bun/swap-parsing.ts | 27 +- projects/keepkey-vault/src/bun/swap-report.ts | 308 +++++++++ .../keepkey-vault/src/bun/swap-tracker.ts | 158 +++-- projects/keepkey-vault/src/bun/swap.ts | 93 ++- .../keepkey-vault/src/bun/txbuilder/cosmos.ts | 14 +- .../keepkey-vault/src/bun/txbuilder/utxo.ts | 19 +- .../keepkey-vault/src/bun/txbuilder/xrp.ts | 10 +- .../src/mainview/components/Dashboard.tsx | 10 + .../src/mainview/components/SwapDialog.tsx | 508 ++++++++++++--- .../mainview/components/SwapHistoryDialog.tsx | 601 ++++++++++++++---- .../src/mainview/components/SwapTracker.tsx | 6 + projects/keepkey-vault/src/shared/chains.ts | 43 ++ .../keepkey-vault/src/shared/rpc-schema.ts | 7 +- projects/keepkey-vault/src/shared/types.ts | 51 ++ 17 files changed, 1885 insertions(+), 267 deletions(-) create mode 100644 projects/keepkey-vault/src/bun/swap-report.ts diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 9fe60e6..946b14a 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,197 @@ 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' + + let sql = `UPDATE swap_history SET status = ?, updated_at = ?` + const params: any[] = [status, now] + + if (extra?.outboundTxid) { + sql += `, outbound_txid = ?` + params.push(extra.outboundTxid) + } + if (extra?.error) { + sql += `, error = ?` + params.push(extra.error) + } + if (extra?.receivedOutput) { + sql += `, received_output = ?` + params.push(extra.receivedOutput) + } + if (isFinal) { + const completedAt = extra?.completedAt || now + sql += `, completed_at = ?` + params.push(completedAt) + if (extra?.actualTimeSeconds !== undefined) { + sql += `, actual_time_secs = ?` + params.push(extra.actualTimeSeconds) + } + } + + sql += ` WHERE txid = ?` + params.push(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 f16e758..bd6024a 100644 --- a/projects/keepkey-vault/src/bun/evm-rpc.ts +++ b/projects/keepkey-vault/src/bun/evm-rpc.ts @@ -125,6 +125,29 @@ export async function broadcastEvmTx(rpcUrl: string, signedTxHex: string): Promi 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 166e2c3..89db0c8 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -17,7 +17,7 @@ import { CHAINS, customChainToChainDef } from "../shared/chains" 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" @@ -1424,7 +1424,7 @@ const rpc = BrowserView.defineRPC({ const quote = await getSwapQuote(params) // Cache quote so executeSwap can pass real data to the tracker - const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}` + const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}-${params.slippageBps || 300}` swapQuoteCache.set(cacheKey, quote) // Keep cache small (last 10 quotes) if (swapQuoteCache.size > 10) { @@ -1450,7 +1450,7 @@ const rpc = BrowserView.defineRPC({ }, }) // Look up cached quote for real tracker data - const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}` + const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}-${params.slippageBps || 300}` const cachedQuote = swapQuoteCache.get(cacheKey) if (!cachedQuote) console.warn('[index] No cached quote for swap tracker — using fallback data') // Register swap for tracking (non-blocking) @@ -1483,6 +1483,40 @@ const rpc = BrowserView.defineRPC({ 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 diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 5459d76..996dc9d 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -74,12 +74,33 @@ export function parseQuoteResponse( 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 - const inboundAddress = quote.inbound_address || raw.inbound_address || txParams.vaultAddress + // 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) throw new Error('Quote response missing inbound address') + 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 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..3e34c42 --- /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 (const r of records) { + needSpace(18) + + // Alternate row shading + const rowIdx = records.indexOf(r) + 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 index ed36d5f..35d23e0 100644 --- a/projects/keepkey-vault/src/bun/swap-tracker.ts +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -11,9 +11,10 @@ * - CreatePendingSwap (POST /swaps/pending) * - GetPendingSwap (GET /swaps/pending/{txHash}) */ -import type { PendingSwap, SwapTrackingStatus, SwapStatusUpdate, SwapResult, ExecuteSwapParams, SwapQuote } from '../shared/types' +import type { PendingSwap, SwapTrackingStatus, SwapStatusUpdate, SwapResult, ExecuteSwapParams, SwapQuote, SwapHistoryRecord } from '../shared/types' import { getPioneer } from './pioneer' import { assetToCaip } from './swap-parsing' +import { insertSwapHistory, updateSwapHistoryStatus } from './db' const TAG = '[swap-tracker]' @@ -67,6 +68,7 @@ export function trackSwap( params: ExecuteSwapParams, quote: SwapQuote, ): void { + const now = Date.now() const swap: PendingSwap = { txid: result.txid, fromAsset: params.fromAsset, @@ -83,14 +85,42 @@ export function trackSwap( integration: quote.integration || 'thorchain', status: 'pending', confirmations: 0, - createdAt: Date.now(), - updatedAt: Date.now(), + 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) @@ -113,7 +143,11 @@ export function getPendingSwaps(): PendingSwap[] { /** Dismiss a swap from the tracker (user clicked dismiss) */ export function dismissSwap(txid: string): void { pendingSwaps.delete(txid) - if (pendingSwaps.size === 0) { + // 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() } } @@ -211,6 +245,66 @@ function stopPolling(): void { } } +/** 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' @@ -251,49 +345,19 @@ async function pollAllSwaps(): Promise { console.log(`${TAG} GetPendingSwap ${swap.txid.slice(0, 10)}...: status=${remoteSwap.status}, confirmations=${remoteSwap.confirmations || 0}`) - 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) - - // Check for time estimation data from Pioneer - 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 - - // Update estimated time if Pioneer has better data - if (timeEstimate?.total_swap_seconds && timeEstimate.total_swap_seconds > 0) { - swap.estimatedTime = timeEstimate.total_swap_seconds - } - - // Update expected output if Pioneer reports actual amount - if (remoteSwap.buyAsset?.amount && parseFloat(remoteSwap.buyAsset.amount) > 0) { - swap.expectedOutput = remoteSwap.buyAsset.amount - } - - console.log(`${TAG} Status change: ${swap.txid} → ${newStatus} (confirmations=${confirmations}, outbound=${outboundConfirmations || 0}/${outboundRequiredConfirmations || '?'})`) - pushUpdate(swap) - - if (newStatus === 'completed' || newStatus === 'failed' || newStatus === 'refunded') { - pushComplete(swap) + 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) { diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 8f9a294..5ad4b1d 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -13,7 +13,7 @@ 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 } from './evm-rpc' +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' @@ -116,18 +116,30 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise 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: params.toAddress, - senderAddress: params.fromAddress, + 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 @@ -190,7 +202,9 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): } // Validate the memo contains our destination address (only for UTXO/Cosmos — EVM memos use shorthand/aggregator formats) - if (params.memo && fromChain.chainFamily !== 'evm' && !params.memo.toLowerCase().includes(toAddress.toLowerCase())) { + // 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.`) } @@ -220,7 +234,15 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // ── UTXO chains: send to vault, memo in OP_RETURN ── } else if (fromChain.chainFamily === 'utxo') { - let xpub: string | undefined = getBtcXpub() + // 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([{ @@ -231,7 +253,7 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): }]) xpub = result?.[0]?.xpub } catch (e: any) { - throw new Error(`Failed to get xpub: ${e.message}`) + throw new Error(`Failed to get xpub for ${fromChain.coin}: ${e.message}`) } } @@ -458,18 +480,26 @@ async function buildEvmSwapTx( 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 ORDERING: Approval=nonce N, deposit=nonce N+1 — deposit can't mine before approval. - // RISK: If approval reverts, the deposit tx stays pending forever (nonce gap). - // RECOVERY: User must send a 0-value tx to themselves with nonce N to consume the gap, - // or the deposit will eventually be dropped from the mempool. - console.warn(`${TAG} Approval broadcast (nonce=${nonce}) — deposit will use nonce=${nonce + 1}. ` + - `If approval fails, send a 0-value self-tx with nonce ${nonce} to unstick.`) nonce += 1 } @@ -482,11 +512,20 @@ async function buildEvmSwapTx( 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(depositGasLimit), + gasLimit: toHex(erc20DepositGas), gasPrice: toHex(gasPrice), to: params.router, // ROUTER contract, NOT vault value: '0x0', // no ETH value for ERC-20 swaps @@ -499,15 +538,7 @@ async function buildEvmSwapTx( } else { // ── Native asset swap: asset = 0x0, value = amountWei ── const amountWei = parseUnits(params.amount, fromChain.decimals) - const gasLimit = DEPOSIT_GAS_LIMITS[fromChain.id] || 120000n - const gasFee = gasPrice * gasLimit - - if (nativeBalance < amountWei + gasFee) { - throw new Error( - `Insufficient ${fromChain.symbol}: need ${Number(amountWei + gasFee) / 1e18}, ` + - `have ${Number(nativeBalance) / 1e18}` - ) - } + const staticGasLimit = DEPOSIT_GAS_LIMITS[fromChain.id] || 120000n const data = encodeDepositWithExpiry( params.inboundAddress, // vault address @@ -517,6 +548,24 @@ async function buildEvmSwapTx( 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 ${Number(amountWei + gasFee) / 1e18}, ` + + `have ${Number(nativeBalance) / 1e18}` + ) + } + const unsignedTx = { chainId, addressNList: fromChain.defaultPath, diff --git a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts index 2f4fec9..bf9dd73 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts @@ -57,6 +57,7 @@ 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 @@ -67,7 +68,7 @@ export async function buildCosmosTx( chain: ChainDef, params: BuildCosmosParams, ) { - const { to, memo = '', isMax = false, isSwapDeposit = false, fromAddress } = params + const { to, memo = '', feeLevel = 5, isMax = false, isSwapDeposit = false, fromAddress } = params const denom = chain.denom || chain.symbol.toLowerCase() @@ -108,8 +109,15 @@ 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 + // 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 diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts index c3d010f..e430f7d 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts @@ -102,9 +102,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 } @@ -381,12 +387,15 @@ export async function buildUtxoTx( }) .filter(Boolean) - // OP_RETURN memo - if (memo && memo.trim()) { + // OP_RETURN memo — hex-encode for hdwallet-keepkey protobuf layer + const memoHex = memo && memo.trim() + ? Buffer.from(memo.trim(), 'utf8').toString('hex') + : undefined + if (memoHex) { preparedOutputs.push({ amount: '0', addressType: 'opreturn', - opReturnData: memo, + opReturnData: memoHex, }) } @@ -417,7 +426,7 @@ export async function buildUtxoTx( locktime: 0, fee: String(fee / 10 ** chain.decimals), memo, - // opReturnData at top-level for v1 server contract - ...(memo && memo.trim() ? { opReturnData: memo } : {}), + // opReturnData at top-level for v1 server contract (hex-encoded) + ...(memoHex ? { opReturnData: memoHex } : {}), } } 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/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 3ad183e..db89a46 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -166,6 +166,16 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp 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/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index 0b51c02..43b37d4 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -7,12 +7,12 @@ 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 } from "../lib/rpc" +import { rpcRequest, onRpcMessage } from "../lib/rpc" import { formatBalance } from "../lib/formatting" import { getAssetIcon } from "../../shared/assetLookup" -import { CHAINS } from "../../shared/chains" +import { CHAINS, getExplorerTxUrl } from "../../shared/chains" import type { ChainDef } from "../../shared/chains" -import type { SwapAsset, SwapQuote, ChainBalance } from "../../shared/types" +import type { SwapAsset, SwapQuote, ChainBalance, SwapStatusUpdate, SwapTrackingStatus } from "../../shared/types" import { Z } from "../lib/z-index" // ── Phase state machine ───────────────────────────────────────────── @@ -31,11 +31,16 @@ const DEFAULT_OUTPUT: Record = { 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 ─────────────────────────────────────────────────────────── @@ -77,6 +82,76 @@ const ShieldIcon = () => ( ) +// ── 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); } @@ -95,6 +170,10 @@ const DIALOG_CSS = ` 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 ────────────────────────────────────────────────── @@ -294,6 +373,106 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo 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 @@ -440,6 +619,21 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo 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, @@ -463,7 +657,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo setError(e.message || t("errorSwap")) setPhase('review') } - }, [quote, fromAsset, toAsset, amount, isMax, fromBalance]) + }, [quote, fromAsset, toAsset, amount, isMax, fromBalance, balances]) // ── Reset ───────────────────────────────────────────────────────── const reset = useCallback(() => { @@ -475,6 +669,12 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo setQuote(null) setError(null) setTxid(null) + setBeforeFromBal(null) + setBeforeToBal(null) + setAfterFromBal(null) + setAfterToBal(null) + setShowConfetti(false) + completionFiredRef.current = false hasAutoSelected.current = false }, []) @@ -565,40 +765,137 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo )} - {/* ── SUBMITTED — swap broadcast, awaiting confirmations ─ */} + {/* ── SUBMITTED — live tracking with step progress ──── */} {phase === 'submitted' && txid && fromAsset && toAsset && ( - - {/* Pulsing broadcast indicator — NOT a checkmark */} - - - - - - + + {/* Confetti burst on completion */} + {showConfetti && } + + {/* Top icon — checkmark when done, pulsing clock when in progress */} + {isSwapComplete ? ( + + + + ) : isSwapFailed ? ( + + + + ) : ( + + + + + + )} - - {t("swapSubmitted")} - {t("waitingForConfirmations")} - {t("swapSubmittedDesc")} + {/* Status title */} + + + {isSwapComplete ? t("swapCompleted") : isSwapFailed ? t("swapFailed") : t("swapSubmitted")} + + {!isSwapComplete && !isSwapFailed && ( + {t("waitingForConfirmations")} + )} - {/* ETA */} - {quote?.estimatedTime && quote.estimatedTime > 0 && ( - + + {/* 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 ? "#4ADE80" : "kk.border"} /> + + + = 0 ? "kk.textPrimary" : "kk.textMuted"}>{t("stageInput")} + {swapStep === 0 && liveConfirmations > 0 && ( + {liveConfirmations} {t("confirmations")} + )} + {swapStep > 0 && ( + {t("statusCompleted")} + )} + + + + {/* 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 ? "#4ADE80" : "kk.border"} /> + + + = 1 ? "kk.textPrimary" : "kk.textMuted"}>{t("stageProtocol")} + {swapStep === 1 && ( + {t("statusConfirming")}... + )} + {swapStep > 1 && ( + {t("statusCompleted")} + )} + + + + {/* 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"}>{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 && ( + + borderRadius="lg" px="4" py="2"> {t("estimatedTime")}: {formatTime(quote.estimatedTime)} @@ -606,33 +903,20 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo )} {/* Amount summary */} - + - - {displayAmount} {fromAsset.symbol} - + {displayAmount} {fromAsset.symbol} - - ~{quote?.expectedOutput} {toAsset.symbol} - + ~{quote?.expectedOutput} {toAsset.symbol} - {/* Txid */} + {/* Input Txid */} @@ -641,35 +925,117 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {txid.slice(0, 12)}...{txid.slice(-8)} - + + + {(() => { + const url = getExplorerTxUrl(fromAsset.chainId, txid) + return url ? ( + + ) : null + })()} + - {t("trackingSwap")} + {/* 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))}) + + )} + + + {/* To asset balance change */} + + + + {toAsset.symbol} + + + + {beforeToBal ? formatBalance(beforeToBal) : '-'} + + + + {afterToBal ? formatBalance(afterToBal) : '...'} + + {afterToBal && beforeToBal && ( + + (+{formatBalance((parseFloat(afterToBal) - parseFloat(beforeToBal)).toFixed(8))}) + + )} + + + + + )} {/* Actions */} - - diff --git a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx index cebad0b..5811bd7 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx @@ -1,15 +1,24 @@ /** * SwapHistoryDialog — Full dialog for viewing active + historical swaps. * - * Opened from the SwapTracker floating bubble. - * Shows active swaps at top, completed/failed below. + * 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, useRef } from "react" +import { useState, useEffect, useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" -import { Box, Flex, Text, VStack, HStack, Button } from "@chakra-ui/react" +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 type { PendingSwap, SwapStatusUpdate } from "../../shared/types" +import { getExplorerTxUrl } from "../../shared/chains" +import type { PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryStats, SwapTrackingStatus } from "../../shared/types" + +const ExternalLinkIcon = () => ( + + + + + +) // ── Stage helpers ─────────────────────────────────────────────────── @@ -50,6 +59,12 @@ function formatElapsed(ms: number): string { 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); } @@ -61,6 +76,9 @@ const HISTORY_CSS = ` } ` +type TabId = 'active' | 'history' +type StatusFilter = SwapTrackingStatus | 'all' + // ── Stage indicator ───────────────────────────────────────────────── function StageIndicator({ stage, status }: { stage: 1 | 2 | 3; status: string }) { @@ -93,9 +111,9 @@ function StageIndicator({ stage, status }: { stage: 1 | 2 | 3; status: string }) ) } -// ── Swap card ─────────────────────────────────────────────────────── +// ── Active Swap Card (live polling) ───────────────────────────────── -function SwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (txid: string) => void }) { +function ActiveSwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (txid: string) => void }) { const { t } = useTranslation("swap") const stage = getStage(swap.status) const color = getStatusColor(swap.status) @@ -132,45 +150,33 @@ function SwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (txid: st transition="all 0.2s" _hover={{ borderColor: isFinal ? undefined : 'rgba(35,220,200,0.3)' }} > - {/* Header: asset pair + status */} - - {swap.fromSymbol} - + {swap.fromSymbol} - - {swap.toSymbol} - + {swap.toSymbol} {statusLabel} - {/* Amounts */} {swap.fromAmount} {swap.fromSymbol} → ~{swap.expectedOutput} {swap.toSymbol} - {/* Stage indicator */} - {/* Stage labels */} = 1 ? color : 'kk.textMuted'}>{t("stageInput")} = 2 ? color : 'kk.textMuted'}>{t("stageProtocol")} = 3 ? color : 'kk.textMuted'}>{t("stageOutput")} - {/* Confirmations */} {swap.status === 'confirming' && swap.confirmations > 0 && ( - - {swap.confirmations} {t("confirmations")} - + {swap.confirmations} {t("confirmations")} )} - {/* Output confirmations progress */} {swap.outboundConfirmations !== undefined && swap.outboundRequiredConfirmations !== undefined && ( @@ -181,28 +187,24 @@ function SwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (txid: st )} - {/* Error message */} {swap.error && ( {swap.error} )} - {/* Footer */} {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 && ( + {(() => { + 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 + })()} + + + )} + + + )} + + ) +} + +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 { @@ -234,41 +451,62 @@ interface SwapHistoryDialogProps { export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { const { t } = useTranslation("swap") - const [swaps, setSwaps] = useState([]) - - const fetchSwaps = useCallback(() => { + 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) setSwaps(result) - }) + .then((result) => { if (result) setPendingSwaps(result) }) .catch(() => {}) }, []) - // Fetch on open + // Fetch history from SQLite + const fetchHistory = useCallback(() => { + rpcRequest('getSwapHistory', { + status: statusFilter === 'all' ? undefined : statusFilter, + asset: searchQuery || undefined, + limit: 200, + } as any, 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) fetchSwaps() - }, [open, fetchSwaps]) + if (!open) return + fetchPending() + fetchHistory() + }, [open, fetchPending, fetchHistory]) // Listen for DOM events from SwapDialog useEffect(() => { const handler = () => { - fetchSwaps() - setTimeout(fetchSwaps, 1000) - setTimeout(fetchSwaps, 3000) + fetchPending() + fetchHistory() + setTimeout(fetchPending, 1000) + setTimeout(fetchHistory, 2000) } window.addEventListener('keepkey-swap-executed', handler) return () => window.removeEventListener('keepkey-swap-executed', handler) - }, [fetchSwaps]) + }, [fetchPending, fetchHistory]) // Listen for RPC push updates useEffect(() => { const unsub1 = onRpcMessage('swap-update', (update: SwapStatusUpdate) => { - setSwaps(prev => { + setPendingSwaps(prev => { const idx = prev.findIndex(s => s.txid === update.txid) - if (idx === -1) { - fetchSwaps() - return prev - } + if (idx === -1) { fetchPending(); return prev } const updated = [...prev] updated[idx] = { ...updated[idx], @@ -282,45 +520,56 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { } 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', (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 - }) + const unsub2 = onRpcMessage('swap-complete', () => { + fetchPending() + setTimeout(fetchHistory, 500) }) return () => { unsub1(); unsub2() } - }, [fetchSwaps]) + }, [fetchPending, fetchHistory]) - // Poll while open and there are active swaps + // Poll active swaps const activeSwaps = useMemo(() => - swaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), - [swaps] - ) - - const completedSwaps = useMemo(() => - swaps.filter(s => s.status === 'completed' || s.status === 'failed' || s.status === 'refunded'), - [swaps] + pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), + [pendingSwaps] ) useEffect(() => { if (!open || activeSwaps.length === 0) return - const interval = setInterval(fetchSwaps, 15000) + const interval = setInterval(fetchPending, 15000) return () => clearInterval(interval) - }, [open, activeSwaps.length, fetchSwaps]) + }, [open, activeSwaps.length, fetchPending]) const handleDismiss = useCallback((txid: string) => { rpcRequest('dismissSwap', { txid }).catch(() => {}) - setSwaps(prev => prev.filter(s => s.txid !== txid)) + 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 ( @@ -331,9 +580,9 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { border="1px solid" borderColor={activeSwaps.length > 0 ? 'rgba(35,220,200,0.3)' : 'kk.border'} borderRadius="xl" - w="520px" - maxW="90vw" - maxH="80vh" + w="620px" + maxW="95vw" + maxH="85vh" display="flex" flexDirection="column" overflow="hidden" @@ -342,12 +591,22 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { > {/* Header */} - + {t("swapHistory")} - {swaps.length > 0 && ( - - {swaps.length} - + {stats && ( + + + {stats.completed} + + {stats.failed > 0 && ( + + {stats.failed} + + )} + + {stats.totalSwaps} total + + )} - {/* Body */} - - {swaps.length === 0 ? ( - - - {t("noSwapHistory")} + {/* 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 */ - {/* Active swaps */} - {activeSwaps.length > 0 && ( - <> - - - - {t("activeSwaps")} ({activeSwaps.length}) - - - {activeSwaps.map(swap => ( - - ))} - - )} - - {/* Completed swaps */} - {completedSwaps.length > 0 && ( - <> - {activeSwaps.length > 0 && } - - {t("completedSwaps")} ({completedSwaps.length}) - - {completedSwaps.map(swap => ( - - ))} - + {/* 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 index 9d059fb..5606e45 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx @@ -79,6 +79,12 @@ export function SwapTracker() { 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() } diff --git a/projects/keepkey-vault/src/shared/chains.ts b/projects/keepkey-vault/src/shared/chains.ts index 9c6c23c..811ac09 100644 --- a/projects/keepkey-vault/src/shared/chains.ts +++ b/projects/keepkey-vault/src/shared/chains.ts @@ -48,48 +48,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', @@ -109,6 +125,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', @@ -116,6 +134,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', @@ -123,6 +143,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', @@ -130,42 +152,56 @@ 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: 'digibyte', chain: Chain.Digibyte, coin: 'DigiByte', symbol: 'DGB', 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', @@ -189,6 +225,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) +} + /** Convert a user-added custom EVM chain into a ChainDef */ export function customChainToChainDef(c: CustomChain): ChainDef { return { diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index e0a9978..8cab386 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, SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult, PendingSwap, SwapStatusUpdate } 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. @@ -133,6 +133,11 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 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 } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index ecf02ea..b1830e9 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -474,6 +474,57 @@ export interface SwapStatusUpdate { 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. From c607f7382a2f9b9ee805d2647d029b17b5797f2d Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 10 Mar 2026 21:15:53 -0600 Subject: [PATCH 08/11] fix: address code audit findings for swap feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix feeLevel falsy-zero check (use != null instead of &&) - Add formatWei() for precision-safe bigint→string conversion - Remove non-null assertions on params.router - Type SwapContext.wallet with SwapWallet interface - Add clearSwapCache() for cache invalidation - Replace setInterval with setTimeout recursion to prevent poll stacking - Rehydrate active swaps from SQLite on app restart - Replace fragile SQL string concat with structured setClauses pattern - Fix O(n²) indexOf in swap-report PDF generation - Remove `as any` cast in SwapHistoryDialog - Improve getErc20Decimals fallback comment Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/db.ts | 35 ++++----- projects/keepkey-vault/src/bun/evm-rpc.ts | 2 +- projects/keepkey-vault/src/bun/swap-report.ts | 4 +- .../keepkey-vault/src/bun/swap-tracker.ts | 72 ++++++++++++++----- projects/keepkey-vault/src/bun/swap.ts | 45 ++++++++---- .../mainview/components/SwapHistoryDialog.tsx | 2 +- 6 files changed, 106 insertions(+), 54 deletions(-) diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 946b14a..d2265b9 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -765,33 +765,24 @@ export function updateSwapHistoryStatus( const now = Date.now() const isFinal = status === 'completed' || status === 'failed' || status === 'refunded' - let sql = `UPDATE swap_history SET status = ?, updated_at = ?` - const params: any[] = [status, now] - - if (extra?.outboundTxid) { - sql += `, outbound_txid = ?` - params.push(extra.outboundTxid) - } - if (extra?.error) { - sql += `, error = ?` - params.push(extra.error) - } - if (extra?.receivedOutput) { - sql += `, received_output = ?` - params.push(extra.receivedOutput) - } + // 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) { - const completedAt = extra?.completedAt || now - sql += `, completed_at = ?` - params.push(completedAt) + setClauses.push({ col: 'completed_at', value: extra?.completedAt || now }) if (extra?.actualTimeSeconds !== undefined) { - sql += `, actual_time_secs = ?` - params.push(extra.actualTimeSeconds) + setClauses.push({ col: 'actual_time_secs', value: extra.actualTimeSeconds }) } } - sql += ` WHERE txid = ?` - params.push(txid) + 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) { diff --git a/projects/keepkey-vault/src/bun/evm-rpc.ts b/projects/keepkey-vault/src/bun/evm-rpc.ts index bd6024a..540baeb 100644 --- a/projects/keepkey-vault/src/bun/evm-rpc.ts +++ b/projects/keepkey-vault/src/bun/evm-rpc.ts @@ -83,7 +83,7 @@ export async function getErc20Allowance(rpcUrl: string, tokenContract: string, o /** Get ERC-20 decimals via eth_call */ export async function getErc20Decimals(rpcUrl: string, tokenContract: string): Promise { const result = await ethCall(rpcUrl, tokenContract, '0x313ce567') // decimals() - // Fallback 0x12 = 18 decimal (the standard ERC-20 default) + // If RPC returns empty/zero, fall back to 18 (0x12 hex = 18 decimal, the ERC-20 standard default) return Number(BigInt(result || '0x12')) } diff --git a/projects/keepkey-vault/src/bun/swap-report.ts b/projects/keepkey-vault/src/bun/swap-report.ts index 3e34c42..819e9b6 100644 --- a/projects/keepkey-vault/src/bun/swap-report.ts +++ b/projects/keepkey-vault/src/bun/swap-report.ts @@ -177,11 +177,11 @@ export async function generateSwapPdf(records: SwapHistoryRecord[]): Promise() -let pollTimer: ReturnType | null = null +let pollTimer: ReturnType | null = null let sendMessage: ((msg: string, data: any) => void) | null = null let pioneerVerified = false @@ -60,6 +60,44 @@ export async function initSwapTracker(messageSender: (msg: string, data: any) => 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 */ @@ -217,29 +255,31 @@ function getPollInterval(): number { function startPolling(): void { if (pollTimer) return - schedulePoll() - // Poll immediately on start - pollAllSwaps() + // Poll immediately on start, then schedule next + pollAllSwaps().then(scheduleNextPoll) } -/** Schedule next poll with adaptive interval */ -function schedulePoll(): void { - if (pollTimer) clearInterval(pollTimer) +/** 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() - console.log(`${TAG} Next poll in ${interval / 1000}s`) - pollTimer = setInterval(async () => { + pollTimer = setTimeout(async () => { await pollAllSwaps() - // Re-schedule if interval should change (swap aged into next phase) - const newInterval = getPollInterval() - if (newInterval !== interval) { - schedulePoll() - } + scheduleNextPoll() }, interval) } function stopPolling(): void { if (pollTimer) { - clearInterval(pollTimer) + clearTimeout(pollTimer) pollTimer = null console.log(`${TAG} Stopped polling (no active swaps)`) } diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 5ad4b1d..08f06b6 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -17,7 +17,7 @@ import { getEvmGasPrice, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20 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, parseThorAsset, assetToCaip, THOR_TO_CHAIN } from './swap-parsing' +import { parseQuoteResponse, parseAssetsResponse, assetToCaip } from './swap-parsing' const TAG = '[swap]' @@ -31,6 +31,14 @@ const THORCHAIN_ROUTERS: Record = { 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, @@ -67,6 +75,12 @@ 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) { @@ -147,9 +161,16 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise // ── 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: any + wallet: SwapWallet getAllChains: () => ChainDef[] getRpcUrl: (chain: ChainDef) => string | undefined getBtcXpub: () => string | undefined // selected BTC xpub if available @@ -351,8 +372,8 @@ async function buildEvmSwapTx( gasPrice = fallbackGasPrice } } - if (params.feeLevel && params.feeLevel <= 2) gasPrice = gasPrice * 80n / 100n - else if (params.feeLevel && params.feeLevel >= 8) gasPrice = gasPrice * 150n / 100n + 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 = 0 if (rpcUrl) { @@ -427,8 +448,8 @@ async function buildEvmSwapTx( const totalGas = gasPrice * (approveGasLimit + depositGasLimit) if (nativeBalance < totalGas) { throw new Error( - `Insufficient ${fromChain.symbol} for gas: need ~${Number(totalGas) / 1e18}, ` + - `have ${Number(nativeBalance) / 1e18}` + `Insufficient ${fromChain.symbol} for gas: need ~${formatWei(totalGas)}, ` + + `have ${formatWei(nativeBalance)}` ) } @@ -436,7 +457,7 @@ async function buildEvmSwapTx( let needsApproval = true if (rpcUrl) { try { - const currentAllowance = await getErc20Allowance(rpcUrl, tokenContract, fromAddress, params.router!) + 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) { @@ -447,7 +468,7 @@ async function buildEvmSwapTx( // 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 approveData = encodeApprove(params.router, amountBaseUnits) const approveTx = { chainId, @@ -516,7 +537,7 @@ async function buildEvmSwapTx( let erc20DepositGas = depositGasLimit if (rpcUrl) { erc20DepositGas = await estimateGas(rpcUrl, { - to: params.router!, from: fromAddress, data: depositData, value: '0x0', + to: params.router, from: fromAddress, data: depositData, value: '0x0', }, depositGasLimit) console.log(`${TAG} Estimated deposit gas: ${erc20DepositGas} (fallback: ${depositGasLimit})`) } @@ -552,7 +573,7 @@ async function buildEvmSwapTx( let gasLimit = staticGasLimit if (rpcUrl) { gasLimit = await estimateGas(rpcUrl, { - to: params.router!, from: fromAddress, data, value: toHex(amountWei), + to: params.router, from: fromAddress, data, value: toHex(amountWei), }, staticGasLimit) console.log(`${TAG} Estimated native deposit gas: ${gasLimit} (fallback: ${staticGasLimit})`) } @@ -561,8 +582,8 @@ async function buildEvmSwapTx( if (nativeBalance < amountWei + gasFee) { throw new Error( - `Insufficient ${fromChain.symbol}: need ${Number(amountWei + gasFee) / 1e18}, ` + - `have ${Number(nativeBalance) / 1e18}` + `Insufficient ${fromChain.symbol}: need ${formatWei(amountWei + gasFee)}, ` + + `have ${formatWei(nativeBalance)}` ) } diff --git a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx index 5811bd7..9c1b140 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx @@ -473,7 +473,7 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { status: statusFilter === 'all' ? undefined : statusFilter, asset: searchQuery || undefined, limit: 200, - } as any, 10000) + }, 10000) .then((result) => { if (result) setHistory(result) }) .catch(() => {}) From d689f27c250535eb14524c21b97617382a9746b4 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 11 Mar 2026 13:13:51 -0600 Subject: [PATCH 09/11] feat: add swap history resume, fix UTXO OP_RETURN encoding + DOGE min fee - SwapDialog: add resumeSwap prop to open directly in submitted/tracking phase - SwapHistoryDialog: click active or history swaps to resume full tracking UI - SwapTracker: render resume SwapDialog from history callback - UTXO tx builder: pass raw UTF-8 opReturnData (not hex), remove from outputs array (hdwallet handles OP_RETURN creation + base64 encoding internally) - DOGE: enforce 1 DOGE minimum fee even when no change output exists - Swap memo limit: use THORChain 250-byte protocol limit instead of per-chain - SwapDialog UI refresh: wider layout, compact spacing, inline status icons - Fiat currency selector + context provider for multi-currency display - AnimatedUsd: support fiat context formatting Co-Authored-By: Claude Opus 4.6 --- .../__tests__/swap-parsing.test.ts | 360 +++++++++ projects/keepkey-vault/src/bun/index.ts | 17 +- .../keepkey-vault/src/bun/swap-parsing.ts | 7 +- projects/keepkey-vault/src/bun/swap.ts | 17 +- .../keepkey-vault/src/bun/txbuilder/utxo.ts | 38 +- .../src/mainview/components/AnimatedUsd.tsx | 21 +- .../mainview/components/CurrencySelector.tsx | 91 +++ .../components/DeviceSettingsDrawer.tsx | 6 +- .../src/mainview/components/SwapDialog.tsx | 716 ++++++++++++------ .../mainview/components/SwapHistoryDialog.tsx | 70 +- .../src/mainview/components/SwapTracker.tsx | 16 +- .../src/mainview/i18n/locales/en/swap.json | 4 +- .../src/mainview/lib/fiat-context.tsx | 85 +++ .../src/mainview/lib/formatting.ts | 12 +- projects/keepkey-vault/src/mainview/main.tsx | 5 +- projects/keepkey-vault/src/shared/fiat.ts | 90 +++ .../keepkey-vault/src/shared/rpc-schema.ts | 2 + projects/keepkey-vault/src/shared/types.ts | 5 + 18 files changed, 1271 insertions(+), 291 deletions(-) create mode 100644 projects/keepkey-vault/__tests__/swap-parsing.test.ts create mode 100644 projects/keepkey-vault/src/mainview/components/CurrencySelector.tsx create mode 100644 projects/keepkey-vault/src/mainview/lib/fiat-context.tsx create mode 100644 projects/keepkey-vault/src/shared/fiat.ts 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/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 89db0c8..da0ef68 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -179,7 +179,12 @@ 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', + } } // Callbacks bridge REST → RPC UI @@ -1199,6 +1204,16 @@ 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() + }, // ── API Audit Log ──────────────────────────────────────── getApiLogs: async (params) => { diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 996dc9d..9dc4329 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -116,9 +116,10 @@ export function parseQuoteResponse( ? parseFloat(quote.amountOutMin) : expectedNum * (1 - actualSlippageBps / 10000) - // Estimated time from raw THORNode data - const estimatedTime = raw.inbound_confirmation_seconds || raw.total_swap_seconds - || quote.totalSwapSeconds || quote.estimatedTime || 600 + // 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' diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 08f06b6..52d7ddf 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -61,13 +61,11 @@ const DEPOSIT_GAS_LIMITS: Record = { optimism: 200000n, } -/** Memo length limits by chain family — UTXO OP_RETURN is 80 bytes, others are generous */ -const MEMO_LIMITS: Record = { - utxo: 80, - evm: 1000, - cosmos: 512, - xrp: 512, -} +/** 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 ───────────────────────────────── @@ -232,9 +230,8 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // 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 memoLimit = MEMO_LIMITS[fromChain.chainFamily] || 512 - if (params.memo.length > memoLimit) { - throw new Error(`Swap memo too long for ${fromChain.chainFamily} (${params.memo.length} chars, max ${memoLimit})`) + if (params.memo.length > MEMO_LIMIT) { + throw new Error(`Swap memo too long (${params.memo.length} bytes, THORChain max ${MEMO_LIMIT})`) } console.log(`${TAG} Executing: ${params.fromAsset} → ${params.toAsset}, amount=${params.amount}`) diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts index e430f7d..f25b545 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts @@ -225,19 +225,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 @@ -387,17 +402,12 @@ export async function buildUtxoTx( }) .filter(Boolean) - // OP_RETURN memo — hex-encode for hdwallet-keepkey protobuf layer - const memoHex = memo && memo.trim() - ? Buffer.from(memo.trim(), 'utf8').toString('hex') - : undefined - if (memoHex) { - preparedOutputs.push({ - amount: '0', - addressType: 'opreturn', - opReturnData: memoHex, - }) - } + // 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') @@ -426,7 +436,7 @@ export async function buildUtxoTx( locktime: 0, fee: String(fee / 10 ** chain.decimals), memo, - // opReturnData at top-level for v1 server contract (hex-encoded) - ...(memoHex ? { opReturnData: memoHex } : {}), + // opReturnData at top-level — raw UTF-8 string (hdwallet base64-encodes internally) + ...(memoRaw ? { opReturnData: memoRaw } : {}), } } 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/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/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index be6cb42..097926f 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" @@ -393,9 +394,12 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd {/* Content */} - {/* ── Language ────────────────────────────────────── */} + {/* ── Language & Currency ─────────────────────────── */}
+ + +
{/* ── Device Identity ─────────────────────────────── */} diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index 43b37d4..4ff1299 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -9,10 +9,11 @@ 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 } from "../../shared/types" +import type { SwapAsset, SwapQuote, ChainBalance, SwapStatusUpdate, SwapTrackingStatus, PendingSwap } from "../../shared/types" import { Z } from "../lib/z-index" // ── Phase state machine ───────────────────────────────────────────── @@ -82,6 +83,13 @@ const ShieldIcon = () => ( ) +const SwapInputIcon = () => ( + + + + +) + // ── External link icon ────────────────────────────────────────────── const ExternalLinkIcon = () => ( @@ -190,6 +198,7 @@ interface AssetSelectorProps { 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) @@ -212,7 +221,7 @@ function AssetSelector({ label, selected, assets, onSelect, balances, exclude, d return list.slice(0, 50) }, [assets, search, exclude, nativeOnly]) - const getBalance = useCallback((asset: SwapAsset): string | null => { + 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 @@ -220,9 +229,9 @@ function AssetSelector({ label, selected, assets, onSelect, balances, exclude, d const token = chain.tokens.find(t => t.contractAddress?.toLowerCase() === asset.contractAddress?.toLowerCase() ) - return token ? token.balance : null + return token ? { balance: token.balance, usd: token.balanceUsd || 0 } : null } - return chain.balance + return { balance: chain.balance, usd: chain.balanceUsd || 0 } }, [balances]) const chainIcon = useCallback((asset: SwapAsset) => { @@ -262,7 +271,7 @@ function AssetSelector({ label, selected, assets, onSelect, balances, exclude, d {t("noAssets")} ) : ( filtered.map((asset) => { - const bal = getBalance(asset) + const balInfo = getBalance(asset) return ( {asset.symbol} {asset.name} - {bal && ( - {formatBalance(bal)} + {balInfo && ( + + {formatBalance(balInfo.balance)} + {balInfo.usd > 0 && ( + {fmtCompact(balInfo.usd)} + )} + )}
) @@ -351,11 +365,13 @@ interface SwapDialogProps { chain?: ChainDef balance?: ChainBalance address?: string | null + resumeSwap?: PendingSwap | null } // ── Main SwapDialog ───────────────────────────────────────────────── -export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialogProps) { +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') @@ -366,6 +382,8 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo 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) @@ -520,6 +538,55 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo } }, [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) + 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 @@ -537,6 +604,76 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo 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 @@ -606,6 +743,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo setFromAsset(toAsset) setToAsset(prev) setAmount("") + setFiatAmount("") setIsMax(false) setQuote(null) setPhase('input') @@ -665,6 +803,8 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo setFromAsset(null) setToAsset(null) setAmount("") + setFiatAmount("") + setInputMode('crypto') setIsMax(false) setQuote(null) setError(null) @@ -676,6 +816,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo setShowConfetti(false) completionFiredRef.current = false hasAutoSelected.current = false + hasResumedRef.current = null }, []) const handleClose = useCallback(() => { @@ -711,7 +852,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo if (!open) return null // ── Not swappable ───────────────────────────────────────────────── - if (chain && !SWAP_CHAIN_IDS.has(chain.id)) { + if (chain && !resumeSwap && !SWAP_CHAIN_IDS.has(chain.id)) { return ( @@ -734,15 +875,15 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo border="1px solid" borderColor={phase === 'submitted' ? 'rgba(35,220,200,0.3)' : busy ? 'rgba(255,215,0,0.3)' : 'kk.border'} borderRadius="xl" - w="480px" - maxW="90vw" + w="640px" + maxW="94vw" maxH="90vh" overflow="auto" onClick={(e) => e.stopPropagation()} style={{ animation: 'kkSwapFadeIn 0.2s ease-out' }} > {/* ── Header ──────────────────────────────────────────────── */} - + @@ -757,7 +898,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* ── Body ────────────────────────────────────────────────── */} - + {/* Loading state */} {loadingAssets && ( @@ -767,128 +908,120 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* ── SUBMITTED — live tracking with step progress ──── */} {phase === 'submitted' && txid && fromAsset && toAsset && ( - + {/* Confetti burst on completion */} {showConfetti && } - {/* Top icon — checkmark when done, pulsing clock when in progress */} - {isSwapComplete ? ( - - - - ) : isSwapFailed ? ( - - - - ) : ( - - - - - - )} - - {/* Status title */} - - - {isSwapComplete ? t("swapCompleted") : isSwapFailed ? t("swapFailed") : t("swapSubmitted")} - - {!isSwapComplete && !isSwapFailed && ( - {t("waitingForConfirmations")} + {/* Status icon + title inline */} + + {isSwapComplete ? ( + + + + ) : isSwapFailed ? ( + + + + ) : ( + + + + + )} - + + + {isSwapComplete ? t("swapCompleted") : isSwapFailed ? t("swapFailed") : t("swapSubmitted")} + + {!isSwapComplete && !isSwapFailed && ( + {t("waitingForConfirmations")} + )} + + - {/* ── 3-Step Progress ───────────────────────────────── */} - - + {/* ── 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 ? "#4ADE80" : "kk.border"} /> - - - = 0 ? "kk.textPrimary" : "kk.textMuted"}>{t("stageInput")} - {swapStep === 0 && liveConfirmations > 0 && ( - {liveConfirmations} {t("confirmations")} - )} - {swapStep > 0 && ( - {t("statusCompleted")} + + 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 ? "#4ADE80" : "kk.border"} /> - - - = 1 ? "kk.textPrimary" : "kk.textMuted"}>{t("stageProtocol")} - {swapStep === 1 && ( - {t("statusConfirming")}... - )} - {swapStep > 1 && ( - {t("statusCompleted")} + + 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"}>{t("stageOutput")} - {swapStep === 2 && liveOutboundConfirmations !== undefined && ( - - {liveOutboundConfirmations}{liveOutboundRequired ? `/${liveOutboundRequired}` : ''} {t("confirmations")} - - )} - {swapStep === 2 && liveOutboundConfirmations === undefined && ( - {t("statusOutputDetected")} - )} - {swapStep > 2 && ( - {t("statusCompleted")} + + 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 */} @@ -905,15 +1038,25 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* Amount summary */} - - - {displayAmount} {fromAsset.symbol} - + + + + {displayAmount} {fromAsset.symbol} + + {hasFromPrice && ( + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + )} + - - - ~{quote?.expectedOutput} {toAsset.symbol} - + + + + ~{quote?.expectedOutput} {toAsset.symbol} + + {hasToPrice && quote?.expectedOutput && ( + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + )} + {/* Input Txid */} @@ -933,7 +1076,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo const url = getExplorerTxUrl(fromAsset.chainId, txid) return url ? ( ) : null @@ -961,7 +1104,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo const url = getExplorerTxUrl(toAsset.chainId, liveOutboundTxid) return url ? ( ) : null @@ -984,20 +1127,27 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {fromAsset.symbol} - - - {beforeFromBal ? formatBalance(beforeFromBal) : '-'} - - - - {afterFromBal ? formatBalance(afterFromBal) : '...'} - - {afterFromBal && beforeFromBal && ( - - ({formatBalance((parseFloat(afterFromBal) - parseFloat(beforeFromBal)).toFixed(8))}) + + + + {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 */} @@ -1005,20 +1155,27 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {toAsset.symbol} - - - {beforeToBal ? formatBalance(beforeToBal) : '-'} - - - - {afterToBal ? formatBalance(afterToBal) : '...'} - - {afterToBal && beforeToBal && ( - - (+{formatBalance((parseFloat(afterToBal) - parseFloat(beforeToBal)).toFixed(8))}) + + + + {beforeToBal ? formatBalance(beforeToBal) : '-'} + + + + {afterToBal ? formatBalance(afterToBal) : '...'} + + {afterToBal && beforeToBal && ( + + (+{formatBalance((parseFloat(afterToBal) - parseFloat(beforeToBal)).toFixed(8))}) + + )} + + {hasToPrice && afterToBal && beforeToBal && ( + + +{fmtCompact((parseFloat(afterToBal) - parseFloat(beforeToBal)) * toPriceUsd)} )} - + @@ -1043,46 +1200,56 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* ── SIGNING / APPROVING / BROADCASTING ───────────────── */} {busy && fromAsset && toAsset && ( - - {/* Device icon with pulse */} - - - - - - - - - - {phase === 'approving' ? t("approvingToken") : phase === 'signing' ? t("confirmOnDevice") : t("broadcasting")} - - - {phase === 'signing' ? t("confirmOnDeviceDesc") : phase === 'approving' ? t("approvalRequired") : t("broadcastingDesc")} - - + + {/* 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} - + + + {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")} @@ -1093,7 +1260,12 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {displayAmount} {fromAsset.symbol} - {fromAsset.name} + + {fromAsset.name} + {hasFromPrice && ( + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + )} + @@ -1115,7 +1287,12 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo ~{quote.expectedOutput} {toAsset.symbol} - {toAsset.name} + + {toAsset.name} + {hasToPrice && ( + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + )} + @@ -1132,23 +1309,44 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {t("rate")} - - 1 {fromAsset.symbol} = {formatBalance( - (parseFloat(quote.expectedOutput) / parseFloat(displayAmount || '1')).toString() - )} {toAsset.symbol} - + + + 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} - + + + {formatBalance(quote.minimumOutput)} {toAsset.symbol} + + {hasToPrice && ( + + {fmtCompact(parseFloat(quote.minimumOutput) * toPriceUsd)} + + )} + {t("networkFee")} - - {formatBalance(quote.fees.outbound)} ({quote.fees.totalBps / 100}%) - + + + {formatBalance(quote.fees.outbound)} ({quote.fees.totalBps / 100}%) + + {hasToPrice && ( + + {fmtCompact(parseFloat(quote.fees.outbound) * toPriceUsd)} + + )} + {t("slippage")} @@ -1223,9 +1421,9 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* ── INPUT ────────────────────────────────────────────── */} {!loadingAssets && (phase === 'input' || phase === 'quoting') && ( - + {/* FROM card */} - + {fromBalance ? `${formatBalance(fromBalance)} ${fromAsset.symbol}` : '\u2014'} + {fromBalance && hasFromPrice && ( + + ({fmtCompact(parseFloat(fromBalance) * fromPriceUsd)}) + + )} {fromAddress && ( - + {fromAddress.slice(0, 8)}...{fromAddress.slice(-6)} )} @@ -1253,38 +1456,72 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo )} {fromAsset && ( - - { setAmount(e.target.value); setIsMax(false) }} - placeholder={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" }} - /> - - + <> + {/* 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 && ( @@ -1315,7 +1552,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* TO card */} - + {t("expectedOutput")}: - - {formatBalance(quote.expectedOutput)} {toAsset.symbol} - + + + {formatBalance(quote.expectedOutput)} {toAsset.symbol} + + {hasToPrice && ( + + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + + )} + )} {toAsset && toAddress && ( @@ -1370,7 +1614,7 @@ export function SwapDialog({ open, onClose, chain, balance, address }: SwapDialo {/* ── Footer ──────────────────────────────────────────────── */} {!loadingAssets && phase !== 'submitted' && !busy && phase !== 'review' && ( - + diff --git a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx index 9c1b140..a5e05c3 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx @@ -113,7 +113,7 @@ function StageIndicator({ stage, status }: { stage: 1 | 2 | 3; status: string }) // ── Active Swap Card (live polling) ───────────────────────────────── -function ActiveSwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (txid: string) => void }) { +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) @@ -147,8 +147,10 @@ function ActiveSwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (tx } borderRadius="lg" p="4" + cursor={onResume ? "pointer" : undefined} transition="all 0.2s" _hover={{ borderColor: isFinal ? undefined : 'rgba(35,220,200,0.3)' }} + onClick={() => onResume?.(swap)} > @@ -207,7 +209,7 @@ function ActiveSwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (tx ) : null @@ -225,7 +227,7 @@ function ActiveSwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (tx const url = getExplorerTxUrl(swap.toChainId, swap.outboundTxid) return url ? ( ) : null @@ -233,7 +235,7 @@ function ActiveSwapCard({ swap, onDismiss }: { swap: PendingSwap; onDismiss: (tx {isFinal && ( ) : null @@ -385,7 +387,7 @@ function HistoryCard({ record }: { record: SwapHistoryRecord }) { const url = getExplorerTxUrl(record.toChainId, record.outboundTxid) return url ? ( ) : null @@ -408,7 +410,7 @@ function HistoryCard({ record }: { record: SwapHistoryRecord }) { const url = getExplorerTxUrl(record.fromChainId, record.approvalTxid) return url ? ( ) : null @@ -416,6 +418,47 @@ function HistoryCard({ record }: { record: SwapHistoryRecord }) { )} + + {/* Resume / view swap button */} + {onResume && ( + + )} )} @@ -447,9 +490,10 @@ const STATUS_OPTIONS: { id: StatusFilter; label: string; color: string }[] = [ interface SwapHistoryDialogProps { open: boolean onClose: () => void + onResumeSwap?: (swap: PendingSwap) => void } -export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { +export function SwapHistoryDialog({ open, onClose, onResumeSwap }: SwapHistoryDialogProps) { const { t } = useTranslation("swap") const [tab, setTab] = useState('active') const [pendingSwaps, setPendingSwaps] = useState([]) @@ -699,7 +743,7 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { {activeSwaps.map(swap => ( - + ))} )} @@ -713,7 +757,7 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { {pendingSwaps .filter(s => s.status === 'completed' || s.status === 'failed' || s.status === 'refunded') .map(swap => ( - + )) } @@ -775,7 +819,7 @@ export function SwapHistoryDialog({ open, onClose }: SwapHistoryDialogProps) { ) : ( history.map(record => ( - + )) )} diff --git a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx index 5606e45..407c01c 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx @@ -9,6 +9,7 @@ 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 = ` @@ -23,6 +24,7 @@ export function SwapTracker() { 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(() => { @@ -115,6 +117,11 @@ export function SwapTracker() { setHasNew(false) } + const handleResumeSwap = useCallback((swap: PendingSwap) => { + setHistoryOpen(false) + setResumeSwap(swap) + }, []) + // Don't render if no swaps if (swaps.length === 0) return null @@ -153,7 +160,14 @@ export function SwapTracker() { {/* History dialog */} - setHistoryOpen(false)} /> + setHistoryOpen(false)} onResumeSwap={handleResumeSwap} /> + + {/* Resume swap dialog — opened from history */} + setResumeSwap(null)} + resumeSwap={resumeSwap} + /> ) } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json index ffd1d0a..83b9f57 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json @@ -81,5 +81,7 @@ "swapCompleted": "Swap completed!", "swapFailed": "Swap failed", "trackingSwap": "Tracking swap via THORChain...", - "waitingForConfirmations": "Waiting for confirmations — swap is NOT complete yet" + "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/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 8cab386..00c0166 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -118,6 +118,8 @@ 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 } // ── Reports ────────────────────────────────────────────────────── generateReport: { params: void; response: ReportMeta } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index b1830e9..a709068 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -267,10 +267,15 @@ 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') } // ── RPC param/response types for top-use endpoints ────────────────────── From 0bed746db50f0b8150ecd10b29c4e96ecd8a7831 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 11 Mar 2026 13:26:07 -0600 Subject: [PATCH 10/11] fix: address code review findings for swap feature - C1: Throw on nonce fetch failure instead of silently falling back to 0 (prevents tx replacement/loss on EVM chains) - C3/H2: Quote cache lookup matches by inboundAddress for correctness; cache key includes addresses; delete+set for LRU ordering - H1: Replace process.exit(1) on tracker init failure with graceful degradation (rest of app continues working) - H6: Skip confetti/sound when resuming already-completed swaps from history (set completionFiredRef before entering submitted phase) - M1: Use Buffer.byteLength() for memo validation instead of .length (multi-byte UTF-8 chars could exceed 250-byte THORChain limit) Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/index.ts | 25 ++++++++++++++----- projects/keepkey-vault/src/bun/swap.ts | 15 +++++++---- .../src/mainview/components/SwapDialog.tsx | 4 +++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index da0ef68..d323824 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1439,7 +1439,8 @@ const rpc = BrowserView.defineRPC({ 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}` + 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) { @@ -1464,9 +1465,22 @@ const rpc = BrowserView.defineRPC({ return undefined }, }) - // Look up cached quote for real tracker data - const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}-${params.slippageBps || 300}` - const cachedQuote = swapQuoteCache.get(cacheKey) + // 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 { @@ -1622,8 +1636,7 @@ import('./swap-tracker').then(async ({ initSwapTracker }) => { } }) }).catch((e) => { - console.error('[swap-tracker] FATAL: Failed to initialize swap tracker:', e) - process.exit(1) + console.error('[swap-tracker] Failed to initialize swap tracker (swaps will be unavailable):', e.message || e) }) // Push engine events to WebView diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 52d7ddf..df86f7e 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -230,8 +230,9 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // 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') - if (params.memo.length > MEMO_LIMIT) { - throw new Error(`Swap memo too long (${params.memo.length} bytes, THORChain max ${MEMO_LIMIT})`) + 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}`) @@ -372,19 +373,23 @@ async function buildEvmSwapTx( 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 = 0 + 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}`) } - } else { + } + if (nonce === undefined) { try { const nd = await pioneer.GetNonceByNetwork({ networkId: fromChain.networkId, address: fromAddress }) - nonce = nd?.data?.nonce ?? 0 + 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) { diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index 4ff1299..779ec3b 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -571,6 +571,10 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap 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, From d7592b2d3960d52bbcc718595d36a9cb293262d9 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 11 Mar 2026 13:51:33 -0600 Subject: [PATCH 11/11] feat: add swaps feature flag in settings (default OFF) Adds a Feature Flags section in Settings with a toggle for cross-chain swaps. When disabled, the swap pill, SwapDialog, and SwapTracker are hidden. Persisted in SQLite via swaps_enabled setting. Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/index.ts | 8 ++++ projects/keepkey-vault/src/mainview/App.tsx | 12 ++++-- .../src/mainview/components/AssetPage.tsx | 12 +++++- .../components/DeviceSettingsDrawer.tsx | 42 ++++++++++++++++++- .../mainview/i18n/locales/en/settings.json | 3 ++ .../keepkey-vault/src/shared/rpc-schema.ts | 1 + projects/keepkey-vault/src/shared/types.ts | 1 + 7 files changed, 73 insertions(+), 6 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index d323824..907ade1 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -175,6 +175,7 @@ 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 @@ -184,6 +185,7 @@ function getAppSettings() { pioneerApiBase: getPioneerApiBase(), fiatCurrency: getSetting('fiat_currency') || 'USD', numberLocale: getSetting('number_locale') || 'en-US', + swapsEnabled, } } @@ -1214,6 +1216,12 @@ const rpc = BrowserView.defineRPC({ 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) => { diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 726b1e5..dadb731 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -42,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) @@ -63,7 +64,7 @@ function App() { .then(setAppVersion) .catch(() => {}) rpcRequest("getAppSettings") - .then((s) => setRestApiEnabled(s.restApiEnabled)) + .then((s) => { setRestApiEnabled(s.restApiEnabled); setSwapsEnabled(s.swapsEnabled) }) .catch(() => {}) }, []) @@ -590,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} @@ -615,7 +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/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 74cbaa2..591903e 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -5,7 +5,7 @@ import { FaArrowDown, FaArrowUp, FaExchangeAlt, FaPlus, FaEye, FaEyeSlash, FaShi import { rpcRequest } from "../lib/rpc" import type { ChainDef } from "../../shared/chains" import { BTC_SCRIPT_TYPES, btcAccountPath } 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" @@ -36,6 +36,14 @@ export function AssetPage({ chain, balance, onBack }: AssetPageProps) { 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() @@ -224,7 +232,7 @@ export function AssetPage({ chain, balance, onBack }: AssetPageProps) { const PILLS: { id: AssetView | 'swap'; label: string; icon: typeof FaArrowDown }[] = [ { id: "receive", label: t("receive"), icon: FaArrowDown }, { id: "send", label: t("send"), icon: FaArrowUp }, - { id: "swap", label: t("swap"), icon: FaExchangeAlt }, + ...(swapsEnabled ? [{ id: "swap" as const, label: t("swap"), icon: FaExchangeAlt }] : []), ] // Shared token row renderer diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index 097926f..ae42861 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -145,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("") @@ -241,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) @@ -816,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/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/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 00c0166..a816639 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -120,6 +120,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 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 } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index a709068..4abc1d3 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -276,6 +276,7 @@ export interface AppSettings { 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 ──────────────────────