diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84cd58d9..4f4b3cff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,16 @@ name: Build KeepKey Vault on: push: - branches: [develop, 'release/*'] - tags: ['v*'] + branches: [master, 'release/*'] + # NOTE: deliberately NOT triggering on tag push. action-gh-release creates + # the v* tag when it creates the draft release, which used to trigger a + # SECOND CI run on the tag ref. That rerun rebuilt the unsigned macOS x64 + # tar.zst (plus Linux artifacts and SHA256SUMS.txt) and uploaded them with + # clobber — destroying the signed tar.zst that sign-release-intel had just + # placed. v1.2.16 release hit this and had to be manually repaired. + # If you ever need to re-run CI on a tag, use workflow_dispatch. pull_request: - branches: [develop, main] + branches: [master, 'release/*'] workflow_dispatch: concurrency: @@ -89,11 +95,15 @@ jobs: - name: Build modules (hdwallet + proto-tx-builder) shell: bash run: | - for i in 1 2 3; do - make modules-build && break - echo "Attempt $i failed, retrying in 10s..." - sleep 10 - done + cd modules/hdwallet + yarn install --frozen-lockfile + yarn tsc --build + + cd ../proto-tx-builder + bun install + git submodule update --init osmosis-frontend + npx tsc -p . + test -f dist/index.js - name: Populate device-protocol lib/ shell: bash @@ -144,6 +154,10 @@ jobs: if: runner.os == 'macOS' run: brew install protobuf + - name: Install Linux packaging tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fakeroot lintian dpkg-dev + - name: Build zcash-cli sidecar (macOS) if: runner.os == 'macOS' shell: bash @@ -335,6 +349,170 @@ jobs: echo "=== After cleanup ===" ls -la + - name: Substitute Electrobun Linux core (lower glibc floor) + if: runner.os == 'Linux' + env: + # Tag of our self-built Electrobun core on this repo. Built on + # ubuntu-22.04 against glibc 2.35 so Vault works on Debian 12, + # Ubuntu 22.04, RHEL/Rocky 9, Mint 21, etc. — distros that the + # upstream-shipped libNativeWrapper.so (glibc 2.38) excludes. + # Republish via: make publish-electrobun-linux-x64-core + ELECTROBUN_LINUX_CORE_TAG: 'electrobun-linux-x64-core-v1' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + cd projects/keepkey-vault/artifacts + TAR_ZST=$(ls stable-linux-x64-keepkey-vault.tar.zst 2>/dev/null | head -1) + if [ -z "$TAR_ZST" ]; then echo "No Linux tar.zst found, skipping core substitution"; exit 0; fi + + # Try to download our self-built core. If the release doesn't exist + # yet, don't fail — the audit step downstream will still catch the + # high-glibc upstream binary. This makes the first push to a fresh + # branch land cleanly even before the publish workflow has run. + CORE_URL="https://github.com/${{ github.repository }}/releases/download/${ELECTROBUN_LINUX_CORE_TAG}/electrobun-core-linux-x64.tar.gz" + CORE_TGZ=$(mktemp --suffix=.tar.gz) + if ! curl -fsSL -o "$CORE_TGZ" "$CORE_URL"; then + echo "::warning::Could not fetch $CORE_URL — using upstream-shipped libNativeWrapper.so (glibc 2.38)." + echo "::warning::Run 'make publish-electrobun-linux-x64-core' to build + publish a glibc 2.35 core." + rm -f "$CORE_TGZ" + exit 0 + fi + + echo "Fetched $(du -h "$CORE_TGZ" | cut -f1) of Electrobun Linux core" + + WORK=$(mktemp -d) + trap 'rm -rf "$WORK" "$CORE_TGZ"' EXIT + zstd -d "$TAR_ZST" -o "$WORK/app.tar" --force + tar xf "$WORK/app.tar" -C "$WORK/" + APP_DIR=$(find "$WORK" -maxdepth 1 -type d -name "keepkey-vault" | head -1) + [ -n "$APP_DIR" ] || { echo "::error::no keepkey-vault dir in tar.zst"; exit 1; } + + BIN_DIR="$APP_DIR/bin" + [ -d "$BIN_DIR" ] || { echo "::error::no bin/ dir at $BIN_DIR"; exit 1; } + + # Extract core tarball into a staging dir so we can preview what's there. + STAGE="$WORK/core" + mkdir -p "$STAGE" + tar xzf "$CORE_TGZ" -C "$STAGE" + echo "Core tarball contents:" + ls -la "$STAGE" + + # Substitute every file from the core into bin/ (overwrite). Preserve + # mode + ownership of the original layout. Files we expect: + # launcher, extractor, libNativeWrapper.so, libNativeWrapper_cef.so, + # libasar.so, (optionally) bun + for src in "$STAGE"/*; do + name=$(basename "$src") + dst="$BIN_DIR/$name" + if [ -e "$dst" ]; then + echo " replace bin/$name" + else + echo " add bin/$name" + fi + install -m 0755 "$src" "$dst" + done + + # Repack tar.zst — same name, original location. + (cd "$WORK" && tar cf "$WORK/app.tar" "$(basename "$APP_DIR")") + zstd -19 -o "$TAR_ZST" --force "$WORK/app.tar" + echo "Repacked $TAR_ZST ($(du -h "$TAR_ZST" | cut -f1))" + + - name: Audit Linux glibc footprint + if: runner.os == 'Linux' + env: + # Highest GLIBC_ symbol allowed in any ELF inside the bundle. + # 2.35 = ubuntu-22.04 floor, reached via our self-built Electrobun + # core (electrobun-linux-x64-core-v1) substituted in above. + # Covers Debian 12, Ubuntu 22.04 LTS, RHEL/Rocky 9, Mint 21, + # Pop!_OS 22.04. Republish via `make publish-electrobun-linux-x64-core`. + LINUX_GLIBC_FLOOR: '2.35' + shell: bash {0} # disable -e + pipefail; we manage exit code ourselves + run: | + cd projects/keepkey-vault/artifacts + TAR_ZST=$(ls stable-linux-x64-keepkey-vault.tar.zst 2>/dev/null | head -1) + if [ -z "$TAR_ZST" ]; then echo "No Linux tar.zst found, skipping audit"; exit 0; fi + + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + zstd -d "$TAR_ZST" -o "$WORK/app.tar" --force + tar xf "$WORK/app.tar" -C "$WORK/" + + FLOOR="${LINUX_GLIBC_FLOOR}" + + # version_gt A B -> exit 0 if A > B (semver-ish, two components) + version_gt() { + [ "$1" = "$2" ] && return 1 + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] + } + + REPORT=$(mktemp) + ERRORS=$(mktemp) + { + echo "## Linux glibc footprint (floor: $FLOOR)" + echo "" + echo "| File | Max GLIBC | Status |" + echo "|------|-----------|--------|" + } >> "$REPORT" + + # Find candidate ELF files: shared libs, .node addons, and executables. + # Use null-delimited list to handle any path; never let find errors abort. + find "$WORK" -type f \( -name '*.so' -o -name '*.so.*' -o -name '*.node' -o -perm /u+x \) \ + ! -name '*.js' ! -name '*.json' ! -name '*.ts' ! -name '*.tsx' \ + ! -name '*.png' ! -name '*.html' ! -name '*.css' ! -name '*.md' \ + ! -name '*.txt' ! -name '*.svg' ! -name '*.ico' ! -name '*.icns' \ + -print0 2>/dev/null | sort -z > /tmp/candidates.lst + + fail=0 + examined=0 + while IFS= read -r -d '' f; do + # ELF magic = 0x7F 'E' 'L' 'F' as the first 4 bytes + magic=$(head -c 4 "$f" 2>/dev/null | xxd -p 2>/dev/null) + [ "$magic" = "7f454c46" ] || continue + + examined=$((examined + 1)) + rel="${f#$WORK/}" + + # Extract numeric GLIBC versions only — skip GLIBC_PRIVATE etc. + max=$(strings "$f" 2>/dev/null \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+' \ + | sed 's/^GLIBC_//' \ + | sort -V \ + | tail -1) + + if [ -z "$max" ]; then + echo "| \`$rel\` | (none) | OK |" >> "$REPORT" + continue + fi + + if version_gt "$max" "$FLOOR"; then + echo "| \`$rel\` | $max | **EXCEEDS FLOOR** |" >> "$REPORT" + echo "$rel requires GLIBC_$max (floor: $FLOOR)" >> "$ERRORS" + fail=1 + else + echo "| \`$rel\` | $max | OK |" >> "$REPORT" + fi + done < /tmp/candidates.lst + + echo "" >> "$REPORT" + echo "_Examined $examined ELF files._" >> "$REPORT" + + # Emit report to both job log and GitHub step summary + cat "$REPORT" + cat "$REPORT" >> "$GITHUB_STEP_SUMMARY" + + if [ "$fail" = "1" ]; then + echo "" + echo "Files exceeding the floor:" + sed 's/^/ - /' "$ERRORS" + while IFS= read -r line; do + echo "::error::$line" + done < "$ERRORS" + echo "::error::Linux glibc audit failed — one or more ELFs exceed the floor of $FLOOR" + exit 1 + fi + echo "All $examined ELF files within glibc floor of $FLOOR." + - name: Package AppImage (Linux) if: runner.os == 'Linux' run: | @@ -394,6 +572,159 @@ jobs: # Cleanup rm -rf "$WORK" + - name: Package .deb (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + cd projects/keepkey-vault/artifacts + TAR_ZST=$(ls stable-linux-x64-keepkey-vault.tar.zst 2>/dev/null | head -1) + if [ -z "$TAR_ZST" ]; then echo "No Linux tar.zst found, skipping .deb"; exit 0; fi + + VERSION=$(node -p "require('../package.json').version") + PKG="keepkey-vault" + ARCH="amd64" + DEB_NAME="${PKG}_${VERSION}_${ARCH}.deb" + + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + # The bundle's tar extracts to $WORK/keepkey-vault, so stage the .deb + # under a different name to avoid the cp recursing into itself. + ROOT="$WORK/deb-root" + + # Extract bundle + zstd -d "$TAR_ZST" -o "$WORK/app.tar" --force + tar xf "$WORK/app.tar" -C "$WORK/" + APP_SRC=$(find "$WORK" -maxdepth 1 -type d -name "keepkey-vault" | head -1) + [ -n "$APP_SRC" ] || { echo "::error::No keepkey-vault dir in tarball"; exit 1; } + + # /opt/keepkey-vault/ + /usr/bin shim + .desktop + icon + udev + mkdir -p "$ROOT/opt/keepkey-vault" \ + "$ROOT/usr/bin" \ + "$ROOT/usr/share/applications" \ + "$ROOT/usr/share/icons/hicolor/256x256/apps" \ + "$ROOT/usr/share/doc/$PKG" \ + "$ROOT/lib/udev/rules.d" \ + "$ROOT/DEBIAN" + + cp -a "$APP_SRC"/. "$ROOT/opt/keepkey-vault/" + + # All file content is written via printf below to avoid YAML-indented + # heredoc whitespace bleed (a 10-space prefix in dpkg control == invalid). + # Each block is one printf with explicit \n line breaks. + + # /usr/bin shim + printf '%s\n' \ + '#!/bin/sh' \ + 'exec /opt/keepkey-vault/bin/launcher "$@"' \ + > "$ROOT/usr/bin/keepkey-vault" + chmod 0755 "$ROOT/usr/bin/keepkey-vault" + + # Icon + if [ -f "$ROOT/opt/keepkey-vault/Resources/appIcon.png" ]; then + cp "$ROOT/opt/keepkey-vault/Resources/appIcon.png" \ + "$ROOT/usr/share/icons/hicolor/256x256/apps/keepkey-vault.png" + fi + + # .desktop + printf '%s\n' \ + '[Desktop Entry]' \ + 'Name=KeepKey Vault' \ + 'Exec=/usr/bin/keepkey-vault %U' \ + 'Icon=keepkey-vault' \ + 'Type=Application' \ + 'Categories=Finance;Utility;' \ + 'Comment=Desktop companion for KeepKey hardware wallet' \ + 'Terminal=false' \ + > "$ROOT/usr/share/applications/keepkey-vault.desktop" + + # udev rules — let regular users access KeepKey USB without root + printf '%s\n' \ + '# KeepKey hardware wallet' \ + '# Vendor 0x2B24, PIDs: 0x0001 (HID legacy), 0x0002 (USB/WebUSB modern)' \ + 'SUBSYSTEM=="usb", ATTRS{idVendor}=="2b24", MODE="0666", GROUP="plugdev"' \ + 'KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", MODE="0666", GROUP="plugdev"' \ + > "$ROOT/lib/udev/rules.d/51-keepkey.rules" + + # Copyright stub (lintian wants one) + printf '%s\n' \ + 'Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/' \ + 'Upstream-Name: KeepKey Vault' \ + 'Source: https://github.com/keepkey/keepkey-vault' \ + '' \ + 'Files: *' \ + 'Copyright: KEY HODLERS LLC' \ + 'License: Proprietary' \ + > "$ROOT/usr/share/doc/$PKG/copyright" + + # Compute installed size in KB (excluding DEBIAN dir) + SIZE_KB=$(du -sk "$ROOT" --exclude=DEBIAN | cut -f1) + + # DEBIAN/control — printf so no leading whitespace creeps in + { + printf 'Package: %s\n' "$PKG" + printf 'Version: %s\n' "$VERSION" + printf 'Section: utils\n' + printf 'Priority: optional\n' + printf 'Architecture: %s\n' "$ARCH" + printf 'Installed-Size: %s\n' "$SIZE_KB" + printf 'Depends: libc6 (>= 2.35), libgtk-3-0, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libayatana-appindicator3-1 | libappindicator3-1\n' + printf 'Recommends: udev\n' + printf 'Maintainer: KEY HODLERS LLC \n' + printf 'Homepage: https://keepkey.com\n' + printf 'Description: Desktop companion for KeepKey hardware wallet\n' + printf ' KeepKey Vault is the official desktop application for managing your\n' + printf ' KeepKey hardware wallet. Send and receive crypto, manage assets across\n' + printf ' multiple chains, and swap between supported tokens.\n' + } > "$ROOT/DEBIAN/control" + + # postinst — refresh icon + udev caches + printf '%s\n' \ + '#!/bin/sh' \ + 'set -e' \ + 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then' \ + ' gtk-update-icon-cache -q -f /usr/share/icons/hicolor || true' \ + 'fi' \ + 'if command -v update-desktop-database >/dev/null 2>&1; then' \ + ' update-desktop-database -q /usr/share/applications || true' \ + 'fi' \ + 'if command -v udevadm >/dev/null 2>&1; then' \ + ' udevadm control --reload-rules || true' \ + ' udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || true' \ + 'fi' \ + 'exit 0' \ + > "$ROOT/DEBIAN/postinst" + chmod 0755 "$ROOT/DEBIAN/postinst" + + # postrm — refresh icon cache after removal + printf '%s\n' \ + '#!/bin/sh' \ + 'set -e' \ + 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then' \ + ' gtk-update-icon-cache -q -f /usr/share/icons/hicolor || true' \ + 'fi' \ + 'if command -v update-desktop-database >/dev/null 2>&1; then' \ + ' update-desktop-database -q /usr/share/applications || true' \ + 'fi' \ + 'exit 0' \ + > "$ROOT/DEBIAN/postrm" + chmod 0755 "$ROOT/DEBIAN/postrm" + + # Sanity-print the control file before building + echo "=== DEBIAN/control ===" + cat "$ROOT/DEBIAN/control" + echo "=== layout (sample) ===" + find "$ROOT" -maxdepth 3 -type d | sort + + # Build .deb (xz compression for size; fakeroot so ownership is root:root) + fakeroot dpkg-deb --build -Zxz "$ROOT" "$DEB_NAME" + ls -lh "$DEB_NAME" + + # Sanity-check with lintian (warnings only — don't fail the build) + if command -v lintian >/dev/null 2>&1; then + lintian --suppress-tags binary-without-manpage,no-changelog "$DEB_NAME" || true + fi + - name: Generate checksums run: | cd projects/keepkey-vault/artifacts @@ -446,7 +777,7 @@ jobs: # Remove per-platform checksum files (will regenerate combined) rm -f SHA256SUMS-*.txt SHA256SUMS.txt # Generate checksums for release-worthy files only - shasum -a 256 *.dmg *.AppImage *.exe *.tar.zst 2>/dev/null > SHA256SUMS.txt || true + shasum -a 256 *.dmg *.AppImage *.deb *.exe *.tar.zst 2>/dev/null > SHA256SUMS.txt || true echo "=== Release checksums ===" cat SHA256SUMS.txt diff --git a/.gitignore b/.gitignore index 4aee6deb..53b278cb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ modules/keepkey-firmware/build-emu/ release-windows/ # Developer-local files -.claude/settings.local.json +.claude/ +emulator.img rmdir-result.txt *.bat diff --git a/Makefile b/Makefile index c83b4110..69e0c1ef 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ include .env export ELECTROBUN_DEVELOPER_ID ELECTROBUN_TEAMID ELECTROBUN_APPLEID ELECTROBUN_APPLEIDPASS endif -.PHONY: install dev dev-hmr build build-stable build-canary build-signed prune-bundle dmg clean help vault sign-check verify verify-entitlements publish release upload-dmg upload-all-dmgs sign-release sign-release-intel verify-arch submodules modules-install modules-build modules-clean audit build-zcash-cli build-zcash-cli-debug build-zcash-cli-intel test test-unit test-rest test-zcash-cli test-emu build-intel build-signed-intel build-electrobun-x64-core publish-electrobun-x64-core preflight build-emulators build-emulator-alpha build-emulator-beta build-emulator-release download-emulators download-emulator-alpha download-emulator-beta download-emulator-release emulator-status clean-emulators +.PHONY: install dev dev-hmr build build-stable build-canary build-signed prune-bundle dmg clean help vault sign-check verify verify-entitlements publish release upload-dmg upload-all-dmgs sign-release sign-release-intel verify-arch submodules modules-install modules-build modules-clean audit build-zcash-cli build-zcash-cli-debug build-zcash-cli-intel test test-unit test-rest test-zcash-cli test-emu build-intel build-signed-intel build-electrobun-x64-core publish-electrobun-x64-core build-electrobun-linux-x64-core publish-electrobun-linux-x64-core preflight build-emulator clean-emulator test-emu-python # --- Submodules (auto-init on fresh worktrees/clones) --- @@ -28,9 +28,12 @@ $(STAMP_DIR): @mkdir -p $(STAMP_DIR) $(SUBMODULES_STAMP): .gitmodules | $(STAMP_DIR) - @git submodule update --init --recursive - @# Fetch all remotes (recursive) so upstream-behind checks see latest commits - @git submodule foreach --recursive --quiet 'git fetch --all --prune 2>/dev/null || true' + @git submodule update --init modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun + @# Fetch Vault runtime/build submodules so upstream-behind checks see latest commits. + @# Firmware is emulator-only for Vault releases and is intentionally not a gate here. + @for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun; do \ + git -C "$$mod" fetch --all --prune 2>/dev/null || true; \ + done @touch $@ submodules: $(SUBMODULES_STAMP) @@ -85,6 +88,8 @@ endif @touch $@ build-zcash-cli: $(ZCASH_CLI_STAMP) + @echo "zcash-cli ready for Electrobun packaging:" + @ls -lh $(PROJECT_DIR)/zcash-cli/target/release/zcash-cli test-zcash-cli: cd $(PROJECT_DIR)/zcash-cli && cargo test @@ -210,6 +215,36 @@ publish-electrobun-x64-core: build-electrobun-x64-core echo ""; \ echo "NEXT: Update .github/workflows/build.yml X64_CORE_TAG to $(ELECTROBUN_X64_TAG)" +# --- Electrobun Linux x64 Core (glibc 2.35 floor) --- +# Builds Electrobun's libNativeWrapper.so + friends on Ubuntu 22.04 so the +# resulting Linux Vault bundle works on Debian 12, Ubuntu 22.04, RHEL 9, etc. +# Upstream's prebuilt core ships against glibc 2.38, which excludes those. +# +# Local invocation only works on an actual Ubuntu 22.04 host (or via +# `make publish-electrobun-linux-x64-core` which runs the GH workflow). + +ELECTROBUN_LINUX_REPO ?= keepkey/keepkey-vault +ELECTROBUN_LINUX_TAG ?= electrobun-linux-x64-core-v1 +# Pin to the upstream electrobun ref that matches the npm runtime version. +ELECTROBUN_LINUX_REF ?= v1.13.1 + +build-electrobun-linux-x64-core: + @echo "Building Electrobun Linux x64 core (must run on ubuntu-22.04)..." + ELECTROBUN_REF=$(ELECTROBUN_LINUX_REF) ./scripts/build-electrobun-linux-x64-core.sh + +# Triggers the GitHub workflow that builds + publishes on ubuntu-22.04. +# Direct local publish isn't supported because the .so must be built on Linux. +publish-electrobun-linux-x64-core: + @echo "Dispatching publish-electrobun-linux-x64-core.yml on $(ELECTROBUN_LINUX_REPO)..." + gh workflow run publish-electrobun-linux-x64-core.yml \ + --repo $(ELECTROBUN_LINUX_REPO) \ + --field electrobun_ref=$(ELECTROBUN_LINUX_REF) \ + --field release_tag=$(ELECTROBUN_LINUX_TAG) + @echo "Watch progress: https://github.com/$(ELECTROBUN_LINUX_REPO)/actions/workflows/publish-electrobun-linux-x64-core.yml" + @echo "" + @echo "Once published, the main build workflow will pick it up automatically" + @echo "(see ELECTROBUN_LINUX_CORE_TAG in .github/workflows/build.yml)." + # --- Vault --- $(VAULT_INSTALL_STAMP): $(PROJECT_DIR)/package.json $(PROJECT_DIR)/scripts/patch-electrobun.sh $(PROTO_BUILD_STAMP) $(HDWALLET_BUILD_STAMP) | $(STAMP_DIR) @@ -287,7 +322,7 @@ dmg: verify-arch test: test-zcash-cli test-unit test-unit: - cd $(PROJECT_DIR) && bun test __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/wizard-messaging.test.ts + cd $(PROJECT_DIR) && bun test __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/wizard-messaging.test.ts __tests__/solana-tx.test.ts __tests__/solana-message-parser.test.ts __tests__/solana-instruction-decoder.test.ts __tests__/solana-alt.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts test-integration: test-rest @@ -295,19 +330,54 @@ test-rest: cd $(PROJECT_DIR) && bun test __tests__/rest-api.test.ts test-emu: + @test -f $(HOME)/.keepkey/emulator/libkkemu.dylib || \ + (echo "ERROR: emulator not installed. Run: make build-emulator"; exit 1) cd $(PROJECT_DIR) && bun test tests/emulator/ -# Run python-keepkey consistency tests against the kkemu binary (UDP). -# Launches kkemu, runs pytest, then kills kkemu. -# Uses alpha channel by default; override with: make test-emu-python EMU_CHANNEL=release -EMU_CHANNEL ?= alpha -EMU_VERSION := 7.14.0-$(EMU_CHANNEL) +# --- Emulator (developer feature) --- +# Build the native macOS emulator (libkkemu.dylib + kkemu) from the firmware +# submodule on the current checkout, install the dylib at +# ~/.keepkey/emulator/libkkemu.dylib (where the vault loads it), and place +# the standalone kkemu binary alongside for python-keepkey UDP testing. +# +# No channels — devs bring their own firmware checkout. Switch revs by +# checking out the target ref in modules/keepkey-firmware before running. + +EMU_FW_DIR := modules/keepkey-firmware +EMU_BUILD_DIR := $(EMU_FW_DIR)/build-emu +EMU_INSTALL_DIR := $(HOME)/.keepkey/emulator + +build-emulator: + @echo "=== Building emulator from current $(EMU_FW_DIR) checkout ===" + @cd $(EMU_FW_DIR) && git rev-parse HEAD | xargs -I{} echo " Source SHA: {}" + cd $(EMU_FW_DIR) && git submodule update --init --recursive + rm -rf $(EMU_BUILD_DIR) + mkdir -p $(EMU_BUILD_DIR) + @# KK_DEBUG_LINK=ON: required for the dylib FFI path (DebugLinkDecision parsing). + @# KK_BUILD_DYLIB=ON: produces libkkemu.dylib alongside standalone kkemu. + cd $(EMU_BUILD_DIR) && cmake .. -DKK_EMULATOR=ON -DKK_DEBUG_LINK=ON -DKK_BUILD_DYLIB=ON \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DCMAKE_C_FLAGS="-DPB_NO_PACKED_STRUCTS=1" \ + -DCMAKE_CXX_FLAGS="-DPB_NO_PACKED_STRUCTS=1" + cd $(EMU_BUILD_DIR) && make -j$$(sysctl -n hw.ncpu) kkemu kkemulator_dylib + mkdir -p $(EMU_INSTALL_DIR) + @if [ -f $(EMU_BUILD_DIR)/lib/libkkemu.dylib ]; then \ + cp $(EMU_BUILD_DIR)/lib/libkkemu.dylib $(EMU_INSTALL_DIR)/libkkemu.dylib; \ + echo " Dylib: $(EMU_INSTALL_DIR)/libkkemu.dylib"; \ + else \ + echo "ERROR: libkkemu.dylib missing from build output"; exit 1; \ + fi + cp $(EMU_BUILD_DIR)/bin/kkemu $(EMU_INSTALL_DIR)/kkemu + chmod +x $(EMU_INSTALL_DIR)/kkemu + @echo " Binary: $(EMU_INSTALL_DIR)/kkemu" + @echo "=== Emulator installed ===" +# Run python-keepkey consistency tests against the locally-built kkemu binary. test-emu-python: - @test -x ./firmware/emulators/$(EMU_VERSION)/kkemu || \ - (echo "ERROR: kkemu not found for $(EMU_CHANNEL) channel. Run: make build-emulator-$(EMU_CHANNEL)"; exit 1) - @echo "Starting kkemu ($(EMU_CHANNEL) channel, UDP 11044/11045)..." - @./firmware/emulators/$(EMU_VERSION)/kkemu & KKPID=$$!; \ + @test -x $(EMU_INSTALL_DIR)/kkemu || \ + (echo "ERROR: kkemu not found at $(EMU_INSTALL_DIR)/kkemu. Run: make build-emulator"; exit 1) + @echo "Starting kkemu (UDP 11044/11045)..." + @$(EMU_INSTALL_DIR)/kkemu & KKPID=$$!; \ sleep 1; \ echo "Running python-keepkey tests..."; \ cd modules/keepkey-firmware/deps/python-keepkey/tests && \ @@ -325,86 +395,9 @@ test-emu-python: kill $$KKPID 2>/dev/null; \ exit $$EXIT -# --- Emulator Channels (alpha/beta/release) --- -# Build native macOS emulator (libkkemu.dylib + kkemu) from the firmware submodule. -# Each channel gets its own directory under firmware/emulators//. -# alpha — tracks BitHighlander fork branch tip (moves with new commits) -# beta — pinned to a specific commit SHA (manually promoted) -# release — tracks upstream keepkey/keepkey-firmware master -# -# To promote a new beta, update BETA_PIN_SHA here AND in manifest.json. - -EMU_FW_DIR := modules/keepkey-firmware -EMU_BUILD_DIR := $(EMU_FW_DIR)/build-emu -BETA_PIN_SHA := 9f52bb69f2e32a71f08b31b0c7df788129a0578e -EMU_UPSTREAM_URL := https://github.com/keepkey/keepkey-firmware.git - -# Common cmake emulator build (called by channel-specific targets) -# _EMU_REF can be a branch (origin/release/7.14.0), a remote/branch, or a commit SHA. -_build-emu: - @echo "=== Building emulator for $(_EMU_CHANNEL) channel ===" - @echo " Source: $(_EMU_REF)" - @echo " Output: firmware/emulators/$(_EMU_VERSION)/" - @# Ensure the upstream (keepkey) remote exists — .gitmodules points to the fork, - @# so fresh clones only have origin. The release channel needs keepkey/master. - @cd $(EMU_FW_DIR) && git remote get-url keepkey >/dev/null 2>&1 || \ - (echo " Adding keepkey remote ($(EMU_UPSTREAM_URL))..." && \ - cd $(EMU_FW_DIR) && git remote add keepkey $(EMU_UPSTREAM_URL)) - cd $(EMU_FW_DIR) && git fetch --all --prune 2>/dev/null - cd $(EMU_FW_DIR) && git checkout $(_EMU_REF) - @# Verify we landed on the expected ref (catches typos in SHA) - @ACTUAL=$$(cd $(EMU_FW_DIR) && git rev-parse HEAD); \ - echo " Checked out: $$ACTUAL" - cd $(EMU_FW_DIR) && git submodule update --init --recursive - rm -rf $(EMU_BUILD_DIR) - mkdir -p $(EMU_BUILD_DIR) - cd $(EMU_BUILD_DIR) && cmake .. -DKK_EMULATOR=ON -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_C_FLAGS="-DPB_NO_PACKED_STRUCTS=1" \ - -DCMAKE_CXX_FLAGS="-DPB_NO_PACKED_STRUCTS=1" - cd $(EMU_BUILD_DIR) && make -j$$(sysctl -n hw.ncpu) kkemu - mkdir -p firmware/emulators/$(_EMU_VERSION) - cp $(EMU_BUILD_DIR)/bin/kkemu firmware/emulators/$(_EMU_VERSION)/kkemu - @echo " Binary: firmware/emulators/$(_EMU_VERSION)/kkemu" - @# Check if a shared lib was also built (optional — depends on CMake config) - @if [ -f $(EMU_BUILD_DIR)/lib/libkkemu.dylib ]; then \ - cp $(EMU_BUILD_DIR)/lib/libkkemu.dylib firmware/emulators/$(_EMU_VERSION)/libkkemu.dylib; \ - echo " Dylib: firmware/emulators/$(_EMU_VERSION)/libkkemu.dylib"; \ - fi - chmod +x firmware/emulators/$(_EMU_VERSION)/kkemu - @# Record which commit was actually built - @cd $(EMU_FW_DIR) && git rev-parse HEAD > ../../firmware/emulators/$(_EMU_VERSION)/.build-sha - @echo "=== $(_EMU_CHANNEL) emulator ready ===" - -build-emulator-alpha: - $(MAKE) _build-emu _EMU_CHANNEL=alpha _EMU_VERSION=7.14.0-alpha _EMU_REF=origin/release/7.14.0 - -build-emulator-beta: - $(MAKE) _build-emu _EMU_CHANNEL=beta _EMU_VERSION=7.14.0-beta _EMU_REF=$(BETA_PIN_SHA) - -build-emulator-release: - $(MAKE) _build-emu _EMU_CHANNEL=release _EMU_VERSION=7.14.0-release _EMU_REF=keepkey/master - -build-emulators: build-emulator-alpha build-emulator-beta build-emulator-release - -# Download pre-built emulators (if published as release assets) -download-emulators: - bun firmware/download-emulators.ts - -download-emulator-alpha: - bun firmware/download-emulators.ts --channel alpha - -download-emulator-beta: - bun firmware/download-emulators.ts --channel beta - -download-emulator-release: - bun firmware/download-emulators.ts --channel release - -emulator-status: - bun firmware/download-emulators.ts --status - -clean-emulators: - rm -rf firmware/emulators/7.14.0-alpha firmware/emulators/7.14.0-beta firmware/emulators/7.14.0-release +clean-emulator: rm -rf $(EMU_BUILD_DIR) + rm -f $(EMU_INSTALL_DIR)/libkkemu.dylib $(EMU_INSTALL_DIR)/kkemu clean: modules-clean cd $(PROJECT_DIR) && rm -rf dist node_modules build _build artifacts @@ -476,10 +469,11 @@ release: sign-check build-signed gh release create v$(VERSION) \ --repo $(GITHUB_REPO) \ --title "KeepKey Vault v$(VERSION)" \ + --draft \ --generate-notes \ $(PROJECT_DIR)/artifacts/$(DMG_NAME) \ $$UPDATE_JSON $$TAR_ZST - @echo "Release v$(VERSION) published to $(GITHUB_REPO)" + @echo "Draft release v$(VERSION) created in $(GITHUB_REPO)" # Sign CI-built macOS artifacts and upload to draft release. # Downloads both arm64 and x64 tar.zst from CI, signs all binaries, @@ -596,7 +590,8 @@ sign-release-intel: sign-check # Args: _SRC_TAR (path to tar.zst), _DMG_ARCH (arm64 or x86_64) _sign-one-dmg: @test -f "$(_SRC_TAR)" || (echo "ERROR: $(_SRC_TAR) not found"; exit 1) - @STAGING=$$(mktemp -d); \ + @set -e; \ + STAGING=$$(mktemp -d); \ trap 'rm -rf "$$STAGING"' EXIT; \ echo " Extracting..."; \ zstd -d "$(_SRC_TAR)" -o "$$STAGING/app.tar" --force; \ @@ -633,7 +628,7 @@ _sign-one-dmg: xcrun stapler staple "$$DMG_OUT"; \ echo " Done: $$DMG_OUT" -# Verify that all MacOS/ binaries have required entitlements (allow-jit). +# Verify that all MacOS/ executables have required entitlements (allow-jit). # Use after signing to confirm bun won't crash with SIGTRAP. verify-entitlements: @echo "Verifying entitlements on signed artifacts..." @@ -654,8 +649,16 @@ verify-entitlements: for BIN in "$$APP/Contents/MacOS/"*; do \ [ -f "$$BIN" ] || continue; \ NAME=$$(basename "$$BIN"); \ - file -b "$$BIN" 2>/dev/null | grep -q "Mach-O" || continue; \ - if codesign -d --entitlements :- "$$BIN" 2>/dev/null | grep -q "allow-jit"; then \ + FILE_OUT=$$(file -b "$$BIN" 2>/dev/null); \ + echo "$$FILE_OUT" | grep -q "Mach-O" || continue; \ + if echo "$$FILE_OUT" | grep -q "dynamically linked shared library"; then \ + echo " SKIP: $$NAME is a shared library"; \ + continue; \ + fi; \ + ENTITLEMENTS_OUT=$$(codesign -d --entitlements :- "$$BIN" 2>&1 || true); \ + if echo "$$ENTITLEMENTS_OUT" | grep -q "invalid entitlements blob"; then \ + echo " FAIL: $$NAME has invalid entitlements blob"; FAIL=1; \ + elif echo "$$ENTITLEMENTS_OUT" | grep -q "allow-jit"; then \ echo " PASS: $$NAME has allow-jit"; \ else \ echo " FAIL: $$NAME missing allow-jit"; FAIL=1; \ @@ -703,8 +706,8 @@ help: @echo " make sign-release - Download CI artifacts, sign + repack, upload DMGs + auto-update tar.zst" @echo " make upload-all-dmgs - Upload all signed DMGs to draft release" @echo " make build-zcash-cli - Test + build Zcash CLI sidecar (release)" - @echo " make build-electrobun-x64-core - Cross-compile Electrobun core for Intel Mac (macOS 12)" - @echo " make publish-electrobun-x64-core - Build + publish x64 core to fork releases" + @echo " make build-electrobun-x64-core - Cross-compile Electrobun core for Intel Mac (macOS 13+)" + @echo " make publish-electrobun-x64-core - Build + publish x64 core release" @echo " make build-zcash-cli-intel - Cross-compile Zcash CLI for Intel Mac" @echo " make build-zcash-cli-debug - Test + build Zcash CLI sidecar (debug)" @echo " make test-zcash-cli - Run Zcash CLI unit tests only" @@ -712,21 +715,18 @@ help: @echo " make sign-check - Verify signing env vars are configured" @echo " make verify - Verify .app bundle signature + Gatekeeper" @echo " make publish - Show distribution artifacts" - @echo " make release - Build, sign, and create new GitHub release" + @echo " make release - Build, sign, and create a draft GitHub release" @echo " make upload-dmg - Upload signed DMG to existing CI draft release" @echo " make test - Run all tests" @echo " make test-rest - Run REST API integration tests (requires running vault)" @echo " make clean - Remove all build artifacts and node_modules" @echo " make preflight - Pre-release validation (pins, CI, builds, typecheck)" @echo "" - @echo "Emulator Channels:" - @echo " make build-emulators - Build all 3 emulator channels from firmware submodule" - @echo " make build-emulator-alpha - Build alpha (BitHighlander fork, release/7.14.0)" - @echo " make build-emulator-beta - Build beta (BitHighlander fork, release/7.14.0)" - @echo " make build-emulator-release - Build release (upstream keepkey/keepkey-firmware master)" - @echo " make download-emulators - Download pre-built emulator binaries" - @echo " make emulator-status - Show installed emulator channels" - @echo " make clean-emulators - Remove all built emulator binaries" + @echo "Emulator (developer feature, macOS only):" + @echo " make build-emulator - Build kkemu+libkkemu from current firmware submodule checkout" + @echo " and install to ~/.keepkey/emulator/" + @echo " make test-emu-python - Run python-keepkey UDP tests against the installed kkemu" + @echo " make clean-emulator - Remove the installed dylib + binary" # --- Pre-release Validation --- preflight: submodules @@ -736,20 +736,18 @@ preflight: submodules @echo "" @echo "1. SUBMODULE PINS" @fail=0; \ - for mod in modules/hdwallet modules/proto-tx-builder modules/keepkey-firmware modules/device-protocol modules/electrobun; do \ + for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun; do \ pinned=$$(git ls-tree HEAD "$$mod" | awk '{print substr($$3,1,12)}'); \ actual=$$(cd "$$mod" && git rev-parse --short=12 HEAD 2>/dev/null); \ if [ "$$pinned" = "$$actual" ]; then echo " ✅ $$mod"; \ else echo " ❌ $$mod DRIFT (pin=$$pinned actual=$$actual)"; fail=1; fi; \ done; \ echo ""; \ - echo "2. FIRMWARE NESTED SUBMODULES"; \ - drift=$$(cd modules/keepkey-firmware && git submodule status --recursive | grep '^+' || true); \ - if [ -n "$$drift" ]; then echo " ❌ DRIFT:"; echo "$$drift"; fail=1; \ - else echo " ✅ All clean"; fi; \ + echo "2. FIRMWARE SUBMODULE"; \ + echo " ⚠️ Skipped for Vault release gating (emulator/firmware work only)"; \ echo ""; \ echo "3. UPSTREAM BEHIND"; \ - for pair in "modules/hdwallet|origin/master" "modules/proto-tx-builder|origin/main" "modules/device-protocol|origin/master" "modules/keepkey-firmware|origin/master" "modules/electrobun|origin/keepkey/macos-12-support"; do \ + for pair in "modules/hdwallet|origin/master" "modules/proto-tx-builder|origin/main" "modules/device-protocol|origin/master" "modules/electrobun|origin/main"; do \ mod="$${pair%%|*}"; ref="$${pair##*|}"; \ behind=$$(cd "$$mod" && git rev-list --count HEAD.."$$ref" 2>/dev/null || echo "?"); \ if [ "$$behind" = "0" ]; then echo " ✅ $$mod"; \ @@ -757,7 +755,7 @@ preflight: submodules done; \ echo ""; \ echo "4. CI STATUS (checks pinned commit, falls back to fork repo for cross-fork PRs)"; \ - for pair in "modules/hdwallet|keepkey/hdwallet|keepkey/hdwallet" "modules/proto-tx-builder|BitHighlander/proto-tx-builder|BitHighlander/proto-tx-builder" "modules/device-protocol|keepkey/device-protocol|keepkey/device-protocol" "modules/keepkey-firmware|keepkey/keepkey-firmware|BitHighlander/keepkey-firmware" "modules/electrobun|BitHighlander/electrobun|BitHighlander/electrobun"; do \ + for pair in "modules/hdwallet|keepkey/hdwallet|keepkey/hdwallet" "modules/proto-tx-builder|BitHighlander/proto-tx-builder|BitHighlander/proto-tx-builder" "modules/device-protocol|keepkey/device-protocol|keepkey/device-protocol" "modules/electrobun|blackboardsh/electrobun|blackboardsh/electrobun"; do \ mod=$$(echo "$$pair" | cut -d'|' -f1); \ repo=$$(echo "$$pair" | cut -d'|' -f2); \ fork=$$(echo "$$pair" | cut -d'|' -f3); \ diff --git a/docs/EMULATOR-CHANNELS.md b/docs/EMULATOR-CHANNELS.md deleted file mode 100644 index 13c584dd..00000000 --- a/docs/EMULATOR-CHANNELS.md +++ /dev/null @@ -1,182 +0,0 @@ -# Emulator Channels SOP - -KeepKey Vault bundles three emulator channels, each tracking a different firmware branch. Before every release, emulators must be rebuilt from their source branches to ensure they match the latest firmware state. - -## Channel Definitions - -| Channel | Source Repo | Ref Type | Ref | Purpose | -|---------|-------------|----------|-----|---------| -| **alpha** | `BitHighlander/keepkey-firmware` | branch | `release/7.14.0` | Latest dev — tracks branch tip, moves with new commits | -| **beta** | `BitHighlander/keepkey-firmware` | commit | `9f52bb69...` | Pre-release candidate — pinned to a specific tested commit | -| **release** | `keepkey/keepkey-firmware` | branch | `master` | Stable upstream — matches what ships on real devices | - -**Alpha** always builds from the branch tip — every `make build-emulator-alpha` picks up new commits. - -**Beta** is pinned to a specific commit SHA. It does NOT move with the branch. To promote a new beta candidate, update `BETA_PIN_SHA` in the Makefile AND `source.ref` in `manifest.json`, then rebuild. - -**Release** tracks upstream master — it's the closest thing to production firmware. - -## Directory Layout - -``` -firmware/emulators/ -├── manifest.json # Channel definitions + source metadata -├── 7.14.0-alpha/ -│ ├── kkemu # CLI emulator binary -│ └── libkkemu.dylib # FFI shared library (loaded by vault) -├── 7.14.0-beta/ -│ ├── kkemu -│ └── libkkemu.dylib -└── 7.14.0-release/ - ├── kkemu - └── libkkemu.dylib -``` - -## Building Emulators - -### Build all channels -```bash -make build-emulators -``` - -### Build a single channel -```bash -make build-emulator-alpha # BitHighlander fork, release/7.14.0 -make build-emulator-beta # BitHighlander fork, release/7.14.0 -make build-emulator-release # Upstream keepkey/keepkey-firmware master -``` - -### Download pre-built binaries (if published) -```bash -make download-emulators -make download-emulator-alpha -``` - -### Check status -```bash -make emulator-status -``` - -## Build Requirements - -- macOS (ARM64 or x86_64) -- CMake 3.x -- A C/C++ compiler toolchain (Xcode CLI tools) -- The firmware submodule initialized: `git submodule update --init modules/keepkey-firmware` - -The build uses `PB_NO_PACKED_STRUCTS=1` for ARM64 nanopb alignment compatibility. - -## Release Verification Checklist - -Before cutting a vault release, verify all three emulators are fresh: - -### 1. Rebuild all channels from source -```bash -make clean-emulators -make build-emulators -``` - -### 2. Verify each channel loads correctly -Start the vault in dev mode and open the emulator panel: -```bash -make dev -``` -In the Emulators panel (bottom-right), select each channel and verify: -- [ ] **Alpha** loads and reaches the onboarding screen -- [ ] **Beta** loads and reaches the onboarding screen -- [ ] **Release** loads and reaches the onboarding screen - -### 3. Verify source branches are current -```bash -# Alpha/Beta source -git -C modules/keepkey-firmware fetch origin -git -C modules/keepkey-firmware log --oneline origin/release/7.14.0 -3 - -# Release source -git -C modules/keepkey-firmware fetch keepkey -git -C modules/keepkey-firmware log --oneline keepkey/master -3 -``` - -### 4. Check binary architectures -```bash -file firmware/emulators/7.14.0-alpha/kkemu -file firmware/emulators/7.14.0-beta/kkemu -file firmware/emulators/7.14.0-release/kkemu -``` - -All should match the target platform (e.g., `Mach-O 64-bit executable arm64`). - -### 5. Run emulator tests per channel -```bash -# Test with each channel's kkemu binary -./firmware/emulators/7.14.0-alpha/kkemu & -# ... run tests, then kill -``` - -## How Channel Selection Works - -1. User opens the Emulators panel in the vault UI -2. Three channel buttons appear: **ALPHA**, **BETA**, **RELEASE** -3. User selects a channel, then starts/imports a wallet -4. The vault loads the corresponding `libkkemu.dylib` via FFI -5. Channel selection is locked while an emulator is running (must stop first to switch) - -## Manifest Format - -The `manifest.json` defines each emulator entry with source tracking: - -```json -{ - "emulators": [ - { - "version": "7.14.0-alpha", - "channel": "alpha", - "source": { - "repo": "BitHighlander/keepkey-firmware", - "ref": "release/7.14.0", - "type": "branch" - } - }, - { - "version": "7.14.0-beta", - "channel": "beta", - "source": { - "repo": "BitHighlander/keepkey-firmware", - "ref": "9f52bb69f2e32a71f08b31b0c7df788129a0578e", - "type": "commit" - } - } - ] -} -``` - -### Promoting a new beta - -1. Identify the commit SHA to pin (test it on alpha first) -2. Update `BETA_PIN_SHA` in the Makefile -3. Update `source.ref` in `manifest.json` for the beta entry -4. Update the beta `description` to note the date and what's included -5. Rebuild: `make build-emulator-beta` - -## Troubleshooting - -### "Emulator dylib not installed for channel X" -The channel's binary hasn't been built yet. Run: -```bash -make build-emulator- -``` - -### Build fails with nanopb alignment errors -Ensure `PB_NO_PACKED_STRUCTS=1` is set. The Makefile handles this automatically. - -### CMake can't find the firmware submodule -```bash -git submodule update --init --recursive modules/keepkey-firmware -``` - -### Wrong architecture (x86_64 binary on ARM64 Mac) -Clean and rebuild: -```bash -make clean-emulators -make build-emulators -``` diff --git a/docs/HANDOFF-emu-finish.md b/docs/HANDOFF-emu-finish.md new file mode 100644 index 00000000..90f1cf53 --- /dev/null +++ b/docs/HANDOFF-emu-finish.md @@ -0,0 +1,135 @@ +# Handoff — finishing the 7.15 emulator work + +**Branch:** `feat/emu-wallet-metadata` (pushed to `origin`, 12 commits on top of `develop`) +**Status:** boots, loads, dashboard renders. **Send is still broken. OLED preview shows no real data.** Two specific things below are the hard blockers; the rest is polish. + +--- + +## Where we are + +**Working end-to-end:** +- Add a new emulator from the bottom-right pill → spawns fresh emu on alpha channel → engine connects → state=`needs_init` → `OobSetupWizard` mounts → user picks Create/Recover → seed loads → state=`ready` → Dashboard renders. +- App restart with a saved emu wallet: stale-storage detection → wipe + auto-reload mnemonic from Keychain → state=`ready` → Dashboard. +- Per-wallet metadata persists: label, firmware version, channel, USD total all surface on the splash device cards alongside real KeepKeys. + +**The 12 commits on this branch** got us from "won't even start the emu" through SIGTRAP/SIGBUS/SIGKILL crashes to "boots and looks right." Memory file `~/.claude/projects/-Users-highlander-…/memory/emu-7.15-debugging.md` has the full bug-by-bug story; read that first before debugging anything emu-related. + +--- + +## What still doesn't work + +### 🔴 BLOCKER 1 — Sending crashes the app + +User reproduction: +1. Boot vault, load a seeded emu wallet, dashboard shows balances +2. Try to send a transaction (Solana SOL native transfer in the user's case) +3. Backend log: + ``` + [solanaSignTx] RPC call received + [solanaSignTx] legacy — fullTx=215B sigCount=1 messageStart=65 + [emu-window] 3 chunks written, polling 2 pre-polls + [emu-window] Waiting for user confirmation (id=69c2a819...) + [emu-window] No emulator window — rejecting (fail closed) + [emu-window] User responded: approved=false + Child process terminated by signal: 9 + ``` + +**Two distinct bugs in this trace:** + +**1a. Emulator window goes missing between dashboard mount and sign click.** +Commit `ddd0a03` partially addressed this — `requestUserConfirm` now auto-reopens the window if `emuWindow` is null. **Not yet retested by the user**; the first thing to do is verify the auto-reopen path actually works. If it does, sign should at least show the confirm dialog. + +If auto-reopen doesn't work, the deeper question is: **why is `emuWindow` null after the dashboard loads?** The window is opened by `emulatorInit` / `emulatorSwitchWallet` and only torn down by explicit close handlers. Something is clearing the `emuWindow` reference. Suspects: +- Wizard transitions calling `closeEmulatorWindow` somewhere (grep `closeEmulatorWindow` callers in `src/bun/index.ts` — there are several in setup paths) +- The `BrowserWindow.on('close')` handler firing because the user closed the window manually after onboarding +- `emulatorImportWallet` (RPC handler at `index.ts:3560-3680`) closes + reopens — race possible + +**1b. SIGKILL after the signing op resolves to `approved=false`.** +This is the watchdog firing again, but the timing is suspicious — `User responded: approved=false` should have triggered an immediate Cancel send to the firmware, then the sign op rejects on the JS side, transport returns an error, RPC returns. No reason for `kkemu_poll` to busy-loop here. + +Hypothesis: when the vault rejects (sends `Cancel` over iface 0), the firmware's `confirm_helper` exits with `ret_stat=false` → `fsm_msgSolanaSignTx` returns `Failure` → vault's hdwallet transport reads it → done. But maybe the vault doesn't actually send `Cancel` — it just resolves the JS promise locally with false. The firmware then sits in confirm_helper waiting for BA/DLD that never come, kkemu_poll busy-loops, watchdog fires. Look at `src/bun/emulator-window.ts:requestUserConfirm` rejection path and check whether it actually writes a Cancel frame to iface 0. + +**Quickest path to confirming this:** run the user's flow with the just-bundled `ddd0a03` and capture the new log. If "No emulator window" is gone but the sign still hangs, it's bug 1b. If it shows the confirm dialog and the user clicks Reject, then it's clearly 1b. + +### 🔴 BLOCKER 2 — OLED preview never shows real device output + +User reports: "I still have yet to see any real data in the emulator preview." Throughout the entire flow — boot, wipe, load, post-load — the emu window only ever shows static placeholder UI (the lock/unlock icon was the only thing visible). The actual firmware-rendered OLED framebuffer is supposed to be drawn into the canvas via the `display-update` packets. + +**Plumbing summary:** +- `src/bun/emulator.ts:emuGetDisplay()` calls `kkemu_get_display` and copies the framebuffer into a Uint8Array. +- `src/bun/emulator-window.ts:startDisplayPoll()` runs every 66ms (`~15 fps`), fetches the framebuffer, base64-encodes it, sends to the webview via `sendToWindow('display-update', { fb, w, h })`. +- The webview's `onDisplayUpdate(data)` decodes base64 and renders into a 256×64 canvas using SSD1306 page format (8 pages × 256 cols, each byte = 8 vertical pixels). + +**What to check first:** +1. Is `kkemu_get_display` actually returning a non-null pointer with `w=256, h=64`? My isolated dylib test did show `fbPtr=4394017601 w=256 h=64` and the first 8 bytes were `00 00 00 00 00 00 00 00` — meaning the framebuffer existed but was all-zeros at that moment. **The firmware may simply not be drawing anything to the OLED in the dylib build.** +2. Is the `display-update` packet actually reaching the webview? Add a `console.log('[emu-ui] onDisplayUpdate w=' + data.w + ' h=' + data.h)` in `emulator-window.ts:onDisplayUpdate` (the inline webview HTML, line ~615) and watch the webview devtools. If you see them, the issue is on the canvas-render side; if not, it's on the bun-send side (`viewReady` race? `sendToWindow` dropping them?). +3. Even if the framebuffer is being delivered, the SSD1306 page-format decoder might be wrong for the 7.15 dylib's framebuffer layout. The 7.10 dylib returned `NULL` early so this code path was never exercised; nobody's verified it actually draws correctly. + +**The lock/unlock icon the user sees is purely the placeholder UI** in the webview HTML (`oled.innerHTML = '
KeepKey Emulator Ready
'` and similar). Real frames would replace it via the `hasRealDisplay = true` branch. + +--- + +## What's solid (don't waste time re-debugging) + +These were all chased down already and have memory + commit messages: + +- **Webview ready handshake** (`9ce826d`): `executeJavascript` no longer races the WKWebView's first paint. +- **Bun `toBuffer` GC bug** (`268f0cb`): `toArrayBuffer().slice()` everywhere. +- **`KK_DEBUG_LINK=ON` cmake flag** (`19d4011`): the actual fix for the confirm-flow hang. Without this, `msg_read_tiny` doesn't recognize `DebugLinkDecision`, confirm_helper busy-loops, watchdog SIGKILLs. +- **Watchdog 60s** (`9b7126a`): old 15s default was too tight for 7.15's slower derivation paths. Don't lower it back. +- **3s deadlines on DebugLink seed verifies** (`da814f2`, `8ce8962`): cosmetic; they hang on the dylib path. Removing them re-introduces the wizard/dashboard hangs. + +--- + +## Out-of-scope but worth flagging + +- **Zcash Orchard on emu** — user said explicitly out of scope. Separate branch. +- **DebugLink reads hang on dylib** — root cause of the timeouts above. If fixed, the workarounds can come out. Likely a poll-thread / interleaving issue in the dylib (`libkkemu.c` ringbuffer logic). +- **python-keepkey reactive flow deadlocks the dylib** — `BitHighlander/python-keepkey @ feat/dylib-transport` has scaffolding (`DylibTransport`, `tests/test_dylib_confirm_flow.py`) that reproduces it deterministically in <30s. Real fix is the same poll-thread in the dylib. +- **`getBalances` per-chain timing instrumented** (`9b7126a`) but not yet diagnosed. Watch for `[getBalances] . took XXXms` lines >2s. +- **Bundled-vs-user-installed emu drag-and-drop UX** — sketched in an earlier conversation. Out of scope for this branch. + +--- + +## Repro environment + +```bash +# From repo root: +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11 + +# Make sure submodule is on the right firmware +git -C modules/keepkey-firmware log --oneline -1 # should be f8fee570 on alpha + +# Build/start dev +make dev # = bun run dev — re-bundles backend + frontend, launches Electrobun + +# Re-build the emulator dylib if you change firmware: +make build-emulator-alpha # honors the new -DKK_DEBUG_LINK=ON via the patched _build-emu rule +``` + +The emulator binary + dylib live at `firmware/emulators/7.15.0-alpha/`. They're committed. Don't delete and re-build unless you've changed firmware code. + +For diagnosing firmware-contract issues *without* electrobun in the loop: + +```bash +cd modules/keepkey-firmware/deps/python-keepkey/tests +PYTHONPATH=..:../keepkeylib \ + KK_TRANSPORT=dylib \ + KK_DYLIB=$PWD/../../../../../../firmware/emulators/7.15.0-alpha/libkkemu.dylib \ + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python \ + python3 -m pytest test_dylib_confirm_flow.py -v +``` + +`Initialize` → `Features` round-trips. Anything that needs reactive ButtonAck currently hangs (out-of-scope dylib bug, not new). + +--- + +## Recommended sequence for the next session + +1. **Verify `ddd0a03` actually fixed the missing-window** — pull, restart, try a send. If the confirm dialog appears, decline it and watch what happens. +2. **If decline → SIGKILL: fix the Cancel-not-sent path in `requestUserConfirm`.** The vault should write a `Cancel` (msg type 20) frame to iface 0 when the user rejects, so the firmware's `confirm_helper` can exit cleanly. +3. **If decline → clean failure but still no OLED preview: instrument `onDisplayUpdate` in the webview HTML.** First confirm packets arrive at all. Then verify the framebuffer bytes look like a real OLED frame (mostly non-zero in the bottom rows where text usually lives). +4. **If display packets aren't arriving: check the firmware actually drives the OLED in the dylib build.** Memory note from earlier sessions said `force_animation_start()` and `animate()` overwrites get involved, and that the 7.10 dylib returned NULL for `kkemu_get_display`. The 7.15 dylib returns a buffer but it may be all-zeros if `display_refresh()` isn't being called from `kkemu_poll`. +5. **Once send + display work, the branch is reviewable.** Open the PR. + +Treat the existing 12 commits as foundation. The two remaining bugs are stop-the-show; the rest is post-merge polish. diff --git a/docs/HANDOFF-final-zcash.md b/docs/HANDOFF-final-zcash.md new file mode 100644 index 00000000..1fcb4461 --- /dev/null +++ b/docs/HANDOFF-final-zcash.md @@ -0,0 +1,449 @@ +# HANDOFF — `final-zcash` branch + +Branched from `develop` (b186c2d, post-PR #132). Two surgical fixes already in. Unfinished work + a tech-debt arc below. + +--- + +## 1) Done this session + +### 1a. REST gate for Zcash endpoints (bug — every `/api/zcash/shielded/*` was returning 503) + +**Cause** — `rest-api.ts:2653` read `engine.state?.firmwareVersion`. `EngineController` has no `state` property; the actual accessor is `engine.getDeviceState()`. The undefined value passed `isChainSupported(chain, undefined)` → `versionCompare(undefined, '7.15.0')` → -1 → false. Every Zcash REST endpoint was gated off regardless of firmware. + +**Fix** — `engine.state?.firmwareVersion` → `engine.getDeviceState().firmwareVersion`. Also derived the error string from `zcashShieldedDef.minFirmware` so the message can't drift again (it was hard-coded to `7.11.0` while the actual minFirmware is `7.15.0`). + +**Verified** — `GET /api/zcash/shielded/status` now returns `{ready: true}`; `GET /api/zcash/shielded/balance` returns `{confirmed: 2860000, ...}` (= 0.0286 ZEC) with bearer auth. + +### 1c. Cache-health detector ignored Zcash → no auto-refresh ever fired for it + +**Cause** — `index.ts:3393` filtered with `!c.hidden`, but Zcash is `hidden: true` (un-hidden by the privacy flag). So the "missing chain" detection set silently excluded Zcash, even when the privacy flag was on. Newly-enabled chains never triggered the dashboard's `needsAutoRefresh = true` path. + +**Fix** — replaced the filter with a small block that mirrors the user-facing dashboard filter: `zcash-shielded` always excluded (it's a token-on-native), `zcash` included iff the privacy flag is on, every other hidden chain stays out. + +### 1d. Shielded ZEC on dashboard (feature, not a bug) + +User wanted shielded balance visible on the Dashboard like a token sub-row. See section 3 below. + +### 1e. Shielded send wedged the emulator → SIGKILL (signal 9 / exit 137) + +**Cause** — the three Zcash send paths (`sendShielded`, `shieldZec`, `deshieldZec`) called `wallet.zcashSignPczt` directly with no `emuSigningOp` wrapper. On the emulator the firmware enters `confirm_helper()` waiting for ButtonAck + DebugLinkDecision, but vault never pre-writes them. `kkemu_poll()` blocks the Bun event loop. The `EmuWatchdog` (60s) fires SIGKILL on the bun process. User's log: `Child process terminated by signal: 9` after `[zcash-shield] Requesting device signatures...`. + +**Fix** — added optional `signWrap?: DeviceSignWrap` to all three sign builders. The RPC handler in `index.ts` constructs a wrap from `emuSigningOp` when `engine.isEmulator`, passing the relevant tx details (chain, recipient, amount, memo) so the user-approval window populates correctly. Wrap applies *only* to the device-signing call — sidecar PCZT build, finalize, and broadcast are still outside the wrap (correct: only the device-side step needs emulator confirm machinery). + +Files: `txbuilder/zcash-shielded.ts`, `txbuilder/zcash-shield.ts`, `txbuilder/zcash-deshield.ts`, `index.ts:~2412-2467`. + +### 1f. Shielded balance icon on dashboard + +The synthetic shielded token now renders as a special `+ {amount} private` sub-row with a shield icon (instead of the generic `1 token` count). Other tokens (if any) still render via the existing `tokensCount` line below it. `Dashboard.tsx:892-911` has the special-case. + +### 1k. Per-session full rescan on Privacy tab open (the actual cure) + +After patching five distinct symptoms (1e-1i) we still hit `could not validate orchard proof`. Empirical inspection of `~/.keepkey/zcash_wallet.db`: +- 8 notes accumulated across multiple sessions +- All `position: NULL` (computed at build time, never persisted) +- `tree_state` table empty (never cached) +- 6 marked `is_spent`, 2 unspent — the unspent set carried into every send untouched + +The test `test_witness_recomputes_root_after_frontier_extension` proved tree construction is correct. So the bug isn't in our witness math — it's in the cached note data. Rather than chase whichever specific row was inconsistent, distrust the entire cached set on first open and re-derive from chain. + +**Fix** — added `zcashVerifiedThisSession` flag (default false). Three points kick the validation: +- `zcashShieldedStatus` (Privacy tab open) → fires `maybeStartBackgroundWalletVerification()` once per session, asynchronously. Frontend already gets `scan-progress` events, so a "Validating wallet…" UX comes for free. +- `zcashShieldedScan` (manual refresh) → marks verified after success. +- `ensureZcashScanFresh` (pre-send) → upgraded to do a full rescan on first call this session (incremental thereafter). +- `zcashShieldedInit` (FVK loaded) → resets verified=false, since notes for a different ak shouldn't carry over. + +Cost: ~30s once on first Privacy tab access. Trade-off accepted vs. the symptom-chasing alternative. + +### 1j. Witness re-derivation tests (pczt_builder) + +Added 4 tests that close the most dangerous gap — existing tests asserted `witness_at_checkpoint_id` returns `Some(_)`, but never that applying the path to its leaf actually re-computes the tree root. The chain's verifier checks the same invariant; without a test we'd see "could not validate orchard proof" with no local repro. + +- `test_witness_recomputes_root_pure_append` — sanity, all-append tree +- `test_witness_recomputes_root_incomplete_shard_with_marked_note` — note in unfinished shard +- `test_witness_recomputes_root_after_frontier_extension` — production shape (insert N-1 roots, walk shard with note, ephemeral frontier) +- `test_witness_recomputes_root_two_marked_notes_split` — marked note in a walked shard with ephemeral frontier above it + +All four pass — so tree code is correct. Run takes 31s in release (large shard sizes); could be sped up with smaller `ShardTree<_, 8, 4>` test trees if it becomes a CI bottleneck. + +### 1i. Min-confirmations gate on spendable notes + +**Cause** — after fix 1h caught the stale-state double-spend, the next deshield attempt failed at broadcast with `could not validate orchard proof`. Auto-scan ran (visible after the always-log fix below), local tree's anchor matched the chain's at lwd_tip_height, witness was extracted — yet the chain's verifier rejected the Halo2 proof. The shielded note we tried to spend was the one we'd received from a shield tx broadcast ~21 minutes earlier (~17 blocks). Notes that recent are vulnerable to: small reorgs shifting their on-chain position, lightwalletd's tree-state lag behind raw cmx scans, and indexer races between the shield tx's mining and tree-state availability. + +**Fix** — added `MIN_CONFIRMATIONS = 10` gate to `wallet_db::get_spendable_notes(max_block_height)`. Both spend builders (`handle_build_pczt`, `handle_build_deshield_pczt`) now ask lightwalletd for the tip first and pass `tip - 10` as the cutoff. If every unspent note is too recent, the user gets an actionable error: `All N unspent notes are within 10 confirmations of the chain tip (X). Wait a few minutes and retry.` instead of a misleading "no spendable notes" or a doomed broadcast. 10 matches zcashd / ywallet / zecwallet defaults. + +Files: `zcash-cli/src/wallet_db.rs`, `zcash-cli/src/main.rs`. `cargo check` clean. + +### 1j. Auto-scan log always prints + +The auto-scan in `ensureZcashScanFresh` previously logged only when `notes_found > 0`. During the post-1h failure debug, we couldn't tell whether the auto-scan had actually run or not. Made the `[zcash-presend] Scan complete: synced_to=X, new_notes=Y` line unconditional. Cheap, makes future debugging trivially observable. + +### 1h. Pre-send auto-scan to prevent stale-note double-spends + +**Cause** — first deshield attempt with the anchor fix in place produced a valid PCZT, the device signed, the sidecar finalized — but the broadcast was rejected with `orchard double-spend: duplicate nullifier (in finalized state: true)`. The sidecar's local note set was 38,933 blocks behind the chain (`synced_to=3282973`, tip=3321906); 2 of the 4 "unspent" notes the builder selected had actually been spent in the unscanned window. There was no in-app indicator the wallet was behind tip, and no automatic catch-up before sends. + +**Fix** — added `ensureZcashScanFresh()` helper at `index.ts:~563` and called it at the top of `zcashShieldedSend`, `zcashShieldZec`, and `zcashDeshieldZec`. The helper invokes `scanOrchardNotes()` which is a no-op when at tip (~tens of ms IPC roundtrip) and incremental from `synced_to` when behind. Failure throws — we'd rather surface "scan failed" than burn a device confirm + Halo2 proof on a doomed tx. + +User-session validation: rescan from the failure state ran in 11.7s, caught up 38,935 blocks, found 2 new notes (from this session's earlier shield), and revealed `notes_unspent` had dropped 4→2 (so the previous deshield was correctly rejected). + +### 1g. Deshield (Orchard → transparent) failed with "Orchard anchor mismatch" + +**Cause** — `pczt_builder.rs::build_deshield_pczt` reconstructs the Orchard commitment tree to extract Merkle witnesses for the input notes: + +1. Inserts every completed subtree root EXCEPT shards containing input notes +2. For shards containing input notes, fetches all leaves and appends them +3. Validates the locally-computed root matches `lwd_client.get_orchard_anchor(lwd_tip_height)` + +Step 3 always failed when input notes lived in completed shards (the common case). The local tree only extended to the end of the last note-bearing shard — but the lightwalletd anchor at `lwd_tip_height` includes every commitment after that, including the partial frontier of the next incomplete shard. The two roots can't match by construction. The shield path already handles this (lines 998-1063 of the same file: walks the incomplete-shard frontier to the tip), but the deshield path was missing the equivalent extension pass. + +**Fix** — added a frontier-extension pass after the per-note-shard loop in `build_deshield_pczt` (`pczt_builder.rs:~1677`). Mirrors the shield path's `plan_incomplete_shard_fetch` + leaf-walk, appending each frontier leaf with `Retention::Ephemeral` (we only need them to make the root match — never need to spend them). Runs only when the latest incomplete shard is **not** in `note_shards`, since per-note-shard already extends to the tip in that case (`shard_end_pos = u64::MAX`, `shard_end_height = lwd_tip_height`) and a second pass would double-append. + +User's session evidence: +- Shield (1 transparent input → 2 Orchard outputs/dummies) succeeded — txid `9293fb3ec1b858b7c07b1da99970802ede7c38e83b41e4a6d83ea6b9c2c0a84e` +- Deshield (4 Orchard notes → transparent) failed at build with `computed=e46d8739… vs expected=6abf09a7…` — local tree was at end of shard 760, lightwalletd anchor was at the tip (~27,180 blocks later) + +Files: `zcash-cli/src/pczt_builder.rs` (one block added). `cargo check` clean — only pre-existing warnings. + +### 1b. Spurious `AUTO-RELOAD VERIFY FAIL` log noise (4 minutes after `ready`) + +**Cause** — `engine-controller.ts:1085-1104` races a 3s deadline against `getEmulatorMnemonic()` so the foreground proceeds. But the underlying `readChunk` (in `emulator-transport.ts`, `READ_TIMEOUT_MS = 240_000`) cannot be cancelled, so the `.then` callback fires ~4 minutes later and logs `AUTO-RELOAD VERIFY FAIL — firmware returned no mnemonic`. Cosmetic, not functional — but confused us into thinking something was broken. + +**Fix** — `verifyAbandoned` flag set when the 3s race wins. The `.then`/`.catch` checks the flag and returns before logging. + +**Better long-term fix (not done)** — plumb an `AbortSignal` through `transport.call → readChunk` so the abandoned read actually stops, instead of wasting 240s of 5ms-poll work. + +--- + +## 2) Open: dashboard shows "no balance" for Zcash transparent — ROOT CAUSE CORRECTED + +Initial diagnosis blamed Pioneer's `/portfolio` for dropping ZEC. **That was wrong.** Empirical recheck: + +| Source | Endpoint | Result | +|---|---|---| +| Vault REST → device | `POST /addresses/utxo` (Zcash, m/44'/133'/0'/0/0) | `t1gwwyCfbRMyQdwo8xXrMGDj3ZqVjhsHWTh` | +| Vault REST → device | `POST /api/pubkeys/batch` (Zcash p2pkh) | `xpub6CKNxyxUckJaggvmby1J1U5jR9zmRBd7aQh6LdaNsAdPJ6A6tfUqeesERcjHsQsLtzcG8mT3EUxroeBP6CrkucELXbqH5dQkQSyPgSxdFfX` | +| Pioneer UTXO indexer | `GET /api/v1/utxo/balance/bip122:00040fe8…/` | `{"balance":"2911530","confirmedBalance":2911530}` ← 0.0291 ZEC | +| Pioneer dispatcher (legacy) | `GET /api/v1/getPubkeyBalance/zcash/` | `{"error":"Network not supported: zcash"…}` | +| **Pioneer batch** | **`POST /api/v1/portfolio`** with full CAIP | **Returns full Zcash entry: balance, priceUsd, valueUsd, networkId, decimals, dataSource. Works.** | +| Vault local cache | `SELECT * FROM balances WHERE chain_id='zcash'` | Has a row: `0.02911530 ZEC / $7.35` — **but for device `282DE83…`, not for active device `5E4E6B69…`** | + +### Actual root cause — split into three independent things + +1. **Pioneer's legacy `/getPubkeyBalance//` dispatcher only knows 11 networks**, and Zcash isn't one. `/portfolio` (the one vault actually uses) is fine. **Out of scope for this branch — owner is pioneer-server repo, narrow fix incoming there.** + +2. **Vault's cache-health staleness detector excluded Zcash from "missing chain" checks**, because `index.ts:3393` filtered with `!c.hidden` and Zcash is `hidden: true` (un-hidden by the privacy flag). So even when Zcash was missing for the active device, no auto-refresh ever fired. ✅ **Fixed this session** — `index.ts:~3393` now mirrors the user-facing dashboard filter (`!c.hidden || (c.id==='zcash' && zcashPrivacyEnabled)` semantics; `zcash-shielded` always excluded since it's a token-on-native). + +3. **Stale device caches accumulating** — the vault.db `balances` table had 8 distinct `device_id`s. Each emulator wipe + reseed yields a different device_id, and old rows are never pruned. Not blocking the user-visible bug, but worth a separate cleanup pass (TTL-based prune, or "drop on engine.disconnectEmulator" hook). + +After fix (2), the next time the dashboard loads with the active device, cache-health flags Zcash as missing → `needsAutoRefresh = true` → `refreshBalances()` fires → Pioneer returns the Zcash entry → cache writes a `zcash` row for the active device → dashboard shows 0.0291 ZEC. Confirmed via raw `POST /portfolio` probe that Pioneer returns valid data — vault's existing matching at `index.ts:1454` handles it correctly (`d.caip === entry.caip` matches). + +--- + +## 3) Done: shielded balance on dashboard (token-on-native) + +✅ Implemented this session at `index.ts:~1499` — after `getBalances` builds `results`, if `zcashPrivacyEnabled && hasFvkLoaded()` we race `getShieldedBalance()` against a 5s deadline (sidecar IPC, normally <100ms but bounded so a wedged sidecar doesn't block the whole getBalances). On success, look up the Zcash native entry in `results`, derive the per-ZEC USD price from the Pioneer-returned `priceUsd` field on the corresponding `pureNatives` entry, and append a synthetic token: + +```ts +{ + symbol: 'zZEC', + name: 'Shielded ZEC', + balance: zecAmount.toFixed(8), + balanceUsd: shieldedUsd, + priceUsd: zecPrice, + caip: 'bip122:00040fe8ec8471911baa1db1266ea15d/orchard:shielded', + contractAddress: 'orchard', + networkId: 'bip122:00040fe8ec8471911baa1db1266ea15d', + decimals: 8, + type: 'shielded', +} +``` + +Then bump `zcashEntry.balanceUsd += shieldedUsd` so the chain card's total reflects both halves. `nativeBalanceUsd` is left alone (only transparent counts as native). + +### Pre-conditions + +Both must be true at the moment `getBalances` runs, otherwise the shielded row is silently skipped: +- privacy flag on — `zcashPrivacyEnabled === true` +- sidecar has the FVK loaded — `hasFvkLoaded() === true` + +Both are verified at runtime — no failure mode beyond "user doesn't see shielded yet". + +### What this depends on + +This only renders correctly **after** issue (2) above lands and the active device's cache gets a Zcash native row. Without a transparent entry to attach the token to, the synthetic shielded token has nowhere to go (the find at `results.find(r => r.chainId === 'zcash')` returns `undefined`). The cache-health fix is what makes the transparent row materialize. + +### Trade-off accepted + +Misuses `tokens[]` semantically (shielded isn't an ERC20-style token), but reuses every existing token render path on dashboard + AssetPage. The alternative — un-hiding `zcash-shielded` as its own dashboard card — would touch sort/filter, watch-only, swap eligibility, and 4–5 other special-cases. User explicitly chose the token-on-native shape. + +--- + +## 4) Tech-debt arc — migrate everything to caip / networkId + +This session exposed the underlying problem: **multiple competing chain identifiers** flowing through the stack, each with its own coverage gaps. + +### Identifiers in play + +| Identifier | Where | Coverage | +|---|---|---| +| `chain.id` (vault internal: `'zcash'`, `'zcash-shielded'`, `'bitcoin'`, …) | `shared/chains.ts`, dashboard, RPC handlers | full (we own it) | +| `chain.networkId` (CAIP-2: `bip122:00040fe8…`) | from `pioneer-caip` | full (canonical) | +| `chain.caip` (CAIP-19: `bip122:…/slip44:133`) | from `pioneer-caip` | full (canonical) | +| `chain.coin` (KeepKey hdwallet name: `'Zcash'`, `'Bitcoin'`) | hdwallet, firmware coin table | per firmware | +| Pioneer **network slug** (`'zcash'`, `'utxo'`, `'ethereum'`, …) | Pioneer dispatcher, `/getPubkeyBalance//` | **11 networks today, no Zcash** | + +The Pioneer slug is the broken one. CAIP/networkId are canonical. The fix is to standardize on networkId end-to-end and stop letting per-chain string slugs leak into transport boundaries. + +### Concrete migration targets (in priority order) + +1. **Pioneer server** — kill the `network` path param in `getPubkeyBalance` and dispatch on networkId. UTXO chains all share an indexer, so the dispatcher becomes "if `networkId.startsWith('bip122:')` route to UTXO". Solves Zcash and removes a class of "Network not supported" bugs forever. +2. **`pioneer.GetPortfolioBalances` in vault** — already takes `caip` per pubkey (good). Verify nothing downstream re-derives the slug from `caip.split('/')` and reintroduces the dispatcher gap. +3. **Pioneer SDK regenerate** — once the spec changes, regenerate the typed client. `node_modules/@pioneer-platform/...` will need a bump. +4. **Vault internal `chain.id`** — keep it for UI affordances (icon path, color, label key), but stop using it as a transport identifier. Anywhere we currently key dispatch on `chain.id`, switch to `chain.networkId`. +5. **`chain.coin`** — keep this one. It's the firmware-side coin name and isn't going anywhere. +6. **Drop the dual `'zcash'` + `'zcash-shielded'` chain entries** if shielded becomes a token-on-native (option 3 above). One ChainDef per network, shielded is a token row. Cleans up `index.ts:1136`, `:1255`, `:2484` and a few other special cases. + +### Why this is worth doing now + +- We shipped Zcash transparent + shielded, and the dashboard still says "no balance" for transparent. That's the third user-visible bug from the same root cause this quarter (Pioneer dispatcher gap → silent skip → "no balance" UX). +- Every new chain we add (TON, TRON were the recent ones) goes through the same audit: "is the slug in Pioneer's dispatcher?" — easy to forget. +- The CAIP layer already exists and is maintained. The slug layer is parallel and contributes no information. + +### Estimate + +- Pioneer server change: half-day. +- SDK regen + vault adoption: half-day. +- Drop `chain.id` from dispatch sites in vault: 1 day (mostly grep + replace + test). +- Drop dual zcash chain entries (optional): 1 day, gated on dashboard token-on-native landing first. + +Maybe 2–3 days of focused work for permanent removal of an entire class of bugs. + +--- + +## 4.5) Retro — six failed cycles on `could not validate orchard proof` + +Five "fixes" landed today; deshield broadcast still fails with `could not +validate orchard proof` after every one. This section is the post-mortem so +the next session doesn't repeat the same patches. + +### What we ruled out + +- **Tree construction bug** — `test_witness_recomputes_root_after_frontier_extension` and three siblings prove our tree assembly produces witnesses that recompute the local root. (1.5s `cargo test`, all green.) +- **Anchor mismatch** — sidecar verifies our local tree's root equals `lwd_client.get_orchard_anchor(lwd_tip_height)` before generating the proof. No mismatch in any failing run. +- **Stale wallet DB** — full rescan from KeepKey release block re-derives the unspent set from chain + FVK. Bug persists. +- **Note recency / reorg risk** — `MIN_CONFIRMATIONS=10` gate; bug reproduces with the same input note at depth 57. +- **Auto-rescan was breaking the UX** — reverted; trust the cached DB, only do incremental. + +### Repro from the last session + +``` +Inputs: 2840000 ZAT from 2 notes + Note 0: block=3282982, pos=49847628 (depth ~38 980, well-confirmed change note) + Note 1: block=3321905, pos=49936404 (depth 57, shield output) +Amount: 100000 ZAT → transparent (t1...) +Anchor: fa0eca618953365b881b11a64f894dc83e92b2675b6850dfd5ec27a5a6956f0c + (verified to match lightwalletd at lwd_tip_height=3321962) +Proof generation: success +Device signatures: 2 of 2 returned +Finalize: success, txid b31343f64d71f667ef5a678bb2894f818957a15709e2e61f4bc02929d594a035 +Broadcast: REJECTED — could not validate orchard proof +``` + +### Hypotheses, ranked + +1. **Note field inconsistency (most likely).** The scanner stored `cmx_chain` alongside decrypted `{recipient, value, rho, rseed}`. The chain's verifier recomputes `cmx_check = commitment(recipient, value, rho, rseed)` and checks it matches the leaf at the witnessed position. If our stored fields don't actually produce `cmx_chain`, proof fails. Could happen if: + - Decryption used a slightly-different IVK than the chain expects (firmware FVK has the historical sign-bit bug; we auto-clear it, but maybe the scanner uses one form and the proof another) + - Scanner stored fields from a different output of the same tx +2. **Spend authorizing signature mismatch.** Device's RedPallas signature is computed under an `ask` derived from the FVK + diversifier path. If the firmware derives `ask` differently from what the verifier expects (possibly the same sign-bit issue rearing in another place), the spendAuthSig is invalid. The Halo2 proof itself contains the public `rk` (randomized verification key), so this would manifest as "proof rejected" in the redjubjub layer (note the error includes `Downcast from BoxError to redjubjub::Error failed`). +3. **Witness path subtle divergence.** Possible but unlikely given the witness tests pass and the anchor matches. +4. **PCZT signature application order.** `Applying Orchard signatures in full-action mode: 2 signatures for 2 actions (2 real spends)` — sigs are applied positionally. If the device returns them in a different order than the actions were sent, sig 0 would authorize action 1 and vice versa. The hdwallet adapter sends actions in `sorted_notes` order (by position), but device replies might come back in submission order; need to verify. + +The error string `Downcast from BoxError to redjubjub::Error failed` is interesting — it says zebra's error wrapping couldn't downcast to a `redjubjub::Error`, but the original error was "could not validate orchard proof". So the failure path goes through redjubjub before the Halo2 verifier even runs. **That points strongly at hypothesis 2: spendAuthSig invalid.** If Halo2 verification was the issue, the error would be from the proof verifier, not redjubjub. + +### Concrete next moves (suggested order) + +**Step 1: Add an in-process verifier in the sidecar.** Right after `build_for_pczt` produces the bundle and before returning to vault, run `orchard::Verifier` (or whatever the librustzcash equivalent is) on the unsigned bundle to confirm the proof is valid against the public inputs. This rules out hypothesis 1 (note field inconsistency) — if local verification fails, the bundle's `(recipient, value, rho, rseed) → cmx` chain is broken and we know to look at the scanner. If local verification passes but chain rejects, hypothesis 2 (spendAuthSig) is confirmed. Estimate: ~50 LOC, ~1h of work. + +**Step 2: Verify spendAuthSig against the action's `rk` locally.** After the device returns each signature, the sidecar should `redjubjub::SpendAuthSig::verify(rk, sighash, sig)` before applying. If verification fails per-signature, the firmware is producing a sig that doesn't bind to `rk`. That's a firmware bug, not a sidecar bug. Estimate: ~20 LOC. + +**Step 3: Bypass note 1 entirely with coin selection.** Implement min-set-covering in `get_spendable_notes` callers — sort notes by value desc, take until covered. For the user's 0.001 ZEC deshield, Note 0 alone (2.74 ZEC) covers; Note 1 wouldn't be selected. Tests this whole hypothesis: if deshield-with-only-note-0 succeeds, the bug is specific to recent notes (or specific to Note 1's data). Estimate: ~30 LOC + tests. + +**Step 4: Diagnostic dump on broadcast failure.** When the chain rejects, log everything we can about the failed tx — sigs hex, action cmxs, witnessed positions, note fields hex, anchor. Lets the next round of investigation start from a captured artifact instead of needing to re-trigger. + +**Step 5: Compare the firmware FVK derivation against an external reference.** Use a known seed → derive Orchard FVK + spend key in Rust → derive `ask` for action 0 → compare against device output. If they diverge, firmware bug isolated. + +**If all of step 1-2 confirm the bundle + signatures are correct**, the problem is wire format (PCZT serialization, action ordering, or the v5 tx encoder). At that point the right move is **Plan C** from the earlier handoff — migrate to `zcash_client_sqlite::WalletDb` + the `librustzcash` PCZT path which is battle-tested by ywallet and zecwallet. + +### Pattern to break + +Every fix this session was driven by symptom + plausible hypothesis, not localized evidence. We've now exhausted what symptom-chasing can do. Steps 1 + 2 above produce **localized evidence** — they tell us which layer is wrong (sidecar's note storage, sidecar's bundle construction, firmware's signature derivation, or wire format). After that, we fix the actual layer instead of patching upstream. + +## 5) Branch state + +- `final-zcash` is at the HEAD of `develop` + the two surgical fixes above (uncommitted in working tree). +- Submodule pointers: `modules/keepkey-firmware` bumped to `alpha @ 11d97d40` via PR #132. +- Working tree carries pre-existing untracked content: `.claude/`, `emulator.img`, `projects/keepkey-sdk/tests/ton/`. + +### Suggested next commits + +1. **One commit, four fixes** — REST gate + verify-leak silencer + cache-health detector + shielded-as-token. All small, all in two files (`bun/index.ts`, `bun/rest-api.ts`, `bun/engine-controller.ts`). Body should mention each. +2. **Verify** — restart dev build, confirm: shielded REST endpoints return ready, dashboard refresh shows `0.0291 ZEC` transparent + `0.0286 zZEC` token row under it, `[cache-health]` no longer logs `0 missing` when Zcash is missing. +3. **Pioneer slug fix** — separate PR in pioneer-server (you're on it). +4. **Stale device cache prune** — small follow-up. Either drop on `engine.disconnectEmulator`, or TTL-based sweep at startup. Currently 8 device IDs in the cache. +5. **Tech-debt migration to caip/networkId** — separate branch when 1–3 are stable. See section 4. + +--- + +## 6) Useful one-liners for next session + +```bash +# Pair to vault REST and store the bearer token (UI must be open to approve) +TOKEN=$(curl -s -X POST :1646/auth/pair -H 'Content-Type: application/json' \ + -d '{"name":"debug","url":"http://localhost"}' | jq -r .apiKey) + +# Probe Zcash sidecar +curl -s :1646/api/zcash/shielded/status # ready? +curl -s -H "Authorization: Bearer $TOKEN" :1646/api/zcash/shielded/balance + +# Get device's Zcash xpub for direct Pioneer probing +curl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -X POST :1646/api/pubkeys/batch \ + -d '{"paths":[{"address_n":[2147483692,2147483781,2147483648],"coin":"Zcash","script_type":"p2pkh"}]}' + +# Confirm Pioneer has the data (UTXO indexer, no auth) +curl -s "https://api.keepkey.info/api/v1/utxo/balance/bip122:00040fe8ec8471911baa1db1266ea15d/" + +# Reproduce the dispatcher gap +curl -s "https://api.keepkey.info/api/v1/getPubkeyBalance/zcash/" +``` + +--- + +## 7) Resolution + +Fixed in commits `a2cbf80` and `a27a152`. First successful deshield broadcast: + +``` +txid 15dff751aa3bd591138ab76ae344899280dd4ae1bdb362214b3754691ef72e8b + 1 spend (0.02730000 ZEC change note) → 0.001 ZEC transparent + 0.0262 ZEC change +``` + +### Two real bugs, both in the sidecar's ZIP-244/ZIP-317 implementation + +**Bug A — ZIP-244 §4.10b (the "could not validate orchard proof" failure)** + +`digest_transparent_sig_for_orchard` always built the full S.2 form +(`hash_type || prevouts || amounts || scripts || sequence || outputs || +txin_sig_digest`). But ZIP-244 §4.10b says: when a transaction has no +transparent inputs (or only a coinbase input), `transparent_sig_digest` +is identical to the txid form — `prevouts || sequence || outputs`, three +sub-digests, no hash_type byte, no per-input digest. + +Deshield is the only path with transparent outputs but no transparent +inputs. Every broadcast hit a sighash mismatch: + +| Path | Layer | Has transparent inputs? | Sig digest form | Worked? | +|---|---|---|---|---| +| Private send | bun → sidecar → device | no, none at all | EMPTY (short-circuit on both sides) | ✅ | +| Shield | bun → sidecar → device | yes | full S.2 (matches both sides) | ✅ | +| **Deshield** | bun → sidecar → device | **no** (outputs only) | ours: full S.2; chain: §4.10b txid form | ❌ | + +The device signed under our wrong sighash, the chain re-derived the right +sighash, the redpallas verification failed, consensus rejected the proof. + +**Fix**: short-circuit `digest_transparent_sig_for_orchard` to return +`digest_transparent_txid` when `inputs.is_empty()`. Same commit also +fixed a (cosmetic) bug in `digest_transparent_txid` itself — it was +including amounts + scripts (sighash-only fields per §4.10) in the txid +digest input, contradicting ZIP-244 §4.5. + +**Bug B — ZIP-317 fee for post-padding orchard action count** + +After Bug A was fixed, the next broadcast got past orchard proof +validation but was rejected with "Unpaid actions is higher than the +limit". ZIP-317 §3 logical_actions counts the FINAL orchard +`action_count` (post-padding), not the pre-padding `max(n_spends, +n_outputs)`. `BundleType::DEFAULT` pads to a 2-action minimum for the +anonymity set. Old code computed fee against the pre-padding count and +underpaid by exactly one action (5000 ZAT) on the standard 1-spend +deshield. + +**Fix**: `zip317_deshield_fee` now floors orchard action count at 2. + +### What broke the symptom-chasing pattern + +Section 4.5 of this handoff (the retro after six failed cycles) called +out the pattern: every fix was symptom-driven, not evidence-driven. +What worked this time was a different methodology: + +1. **Empirical constraint first.** Confirmed private sends still work → + bug is unique to the deshield code path or its interaction with + chain validation. That single data point cut the hypothesis space + roughly in half. + +2. **Localized evidence before code changes.** Instead of writing + another fix patched upstream of the failure, wrote round-trip tests + in the sidecar that compared our v5 serializer + ZIP-244 digests + against `zcash_primitives::transaction::Transaction::read` — the + canonical reference. This is exactly the "Step 1 + 2" the prior + retro proposed but didn't execute. + +3. **The canary mattered.** The `roundtrip_v5_shielded_only` test + passed before the others were even run, validating the test + infrastructure (synthetic bundle, fixture bytes, comparison + helpers). When `roundtrip_v5_hybrid_*` failed, we knew it was a + real divergence, not a test bug. + +4. **The first failure pointed at the wrong bug.** Both hybrid + round-trip tests failed on `txid` mismatch. The instinct was "this + is the bug" — but the chain doesn't validate our reported txid, it + computes its own. Reading the canonical implementation + (`zcash_primitives::transaction::sighash_v5`) is what surfaced + §4.10b: the chain's `transparent_sig_digest` has a special case for + empty vin that we'd missed. The txid mismatch was a symptom of a + broader "we got the digest formulas wrong" problem; §4.10b was the + root cause for broadcast. + +5. **One bug at a time.** Fixed the §4.10b sighash, rebuilt, tried + broadcast. New error (ZIP-317 fee) — different layer. Resisted the + urge to bundle a speculative fix; localized evidence again ("Unpaid + actions" is a fee message, not a sighash one), one-line fix, test, + ship. + +### Specifically what NOT to do next time + +- **Don't propose "Plan C / migrate to `zcash_client_sqlite::WalletDb`" + as a remedy for spec bugs.** Migration would have required a + multi-week rewrite to fix two bugs that were ultimately ~10 lines + changed across two files. Plan C is still the right move if and when + the wallet's note-storage assumptions break under multi-account or + reorg-handling pressure — but it's the wrong tool for "our digest + function is missing a special case." + +- **Don't trust per-sig local verification as proof of correctness.** + `finalize_*_pczt` already does `rk.verify(&sighash, &sig)` per action, + and that always passed — because we verified against the SAME wrong + sighash we sent the device. The chain re-derives sighash from the + wire bytes; if our bytes-to-sighash function diverges from canonical, + local verify and chain verify disagree silently. + +- **Don't write your own ZIP-244 implementation when a reference + exists.** Our `digest_transparent_sig_for_orchard` got both §4.5 + (txid) and §4.10b (empty-vin special case) wrong. The standalone + Rust `zcash_primitives::transaction::sighash_v5` is ~150 lines and + comprehensively tested by the librustzcash maintainers. We should + consider replacing the `zip244` module entirely with calls into + `zcash_primitives` — but that's coupled to the `Authorization` trait + and the `TransparentAuthorizingContext` plumbing, so it's a clean-up + arc, not a quick fix. Until then, the round-trip tests added in + `pczt_builder.rs` give us a regression net against future drift. + +### Regression coverage + +- `zip244::tests::test_transparent_sig_digest_uses_txid_form_when_vin_empty` + — direct unit test for §4.10b +- `pczt_builder::roundtrip_v5_tests::roundtrip_v5_shielded_only` +- `pczt_builder::roundtrip_v5_tests::roundtrip_v5_hybrid_shield` +- `pczt_builder::roundtrip_v5_tests::roundtrip_v5_hybrid_deshield` +- `pczt_builder::tests::test_zip317_deshield_fee_post_padding` + +All run in <1.5s, no device, no network. CI-safe. diff --git a/docs/HANDOFF-firmware-7.14.1-release.md b/docs/HANDOFF-firmware-7.14.1-release.md new file mode 100644 index 00000000..2751e762 --- /dev/null +++ b/docs/HANDOFF-firmware-7.14.1-release.md @@ -0,0 +1,60 @@ +# Firmware 7.14.1 Release Handoff + +## Status +- [x] Code merged to upstream develop (PR #425) +- [x] `release/7.14.1` branch pushed to `keepkey/keepkey-firmware` +- [x] Tag `v7.14.1` pushed to `keepkey/keepkey-firmware` +- [ ] Firmware binary built +- [ ] Firmware binary signed (3/5 key holders) +- [ ] GitHub release published + +## Repo +`https://github.com/keepkey/keepkey-firmware` +Tag: `v7.14.1` → commit `5482e736` + +## Step 1 — Build the firmware binary + +```bash +cd /path/to/keepkey-firmware +git checkout v7.14.1 +git submodule update --init --recursive +./scripts/build/docker/device/release.sh +``` + +Output: `bin/firmware.keepkey.bin` and `bin/firmware.keepkey.elf` + +Compute hashes: +```bash +sha256sum bin/firmware.keepkey.bin +tail -c +257 bin/firmware.keepkey.bin | sha256sum # payload hash (skip 256-byte header) +``` + +Build on multiple machines and confirm hashes match before signing. + +## Step 2 — Sign the binary (3/5 key holders) + +Sign `firmware.keepkey.bin` on air-gapped machine with the firmware signing keys. +Replace the unsigned binary with the signed one. + +Reference: see how `v7.14.0` was signed at `https://github.com/keepkey/keepkey-firmware/releases/tag/v7.14.0` + +## Step 3 — Publish the GitHub release + +A draft release may already exist at `https://github.com/keepkey/keepkey-firmware/releases` (created by the Release CI workflow if it passed). + +If no draft exists, create the release manually: +- Tag: `v7.14.1` +- Title: `Firmware v7.14.1` +- Attach: signed `firmware.keepkey.bin`, `firmware.keepkey.elf`, `HASHES.txt` +- Publish (un-draft) + +## What changed in 7.14.1 + +- feat(solana): SignOffchainMessage with domain-separated envelope +- feat(ton): Ed25519 SignMessage (AdvancedMode-gated) +- feat(tron): TIP-712 SignTypedHash +- feat(tron): TIP-191 SignMessage + VerifyMessage +- feat(emulator): libkkemu shared library + native macOS build +- fix(tron): drop bogus has_signature/has_address on TronTypedDataSignature +- fix: replace volatile+__sync_synchronize with C11 atomics in ringbuf +- Various "potential fix for pull request finding" security hardening commits diff --git a/docs/SIGNING.md b/docs/SIGNING.md index e7bd6450..abf06923 100644 --- a/docs/SIGNING.md +++ b/docs/SIGNING.md @@ -7,18 +7,20 @@ KeepKey Vault ships signed+notarized DMGs for two macOS architectures: | Architecture | Build Method | Electrobun Source | Bun Version | |-------------|-------------|-------------------|-------------| | **arm64** (Apple Silicon) | Native build on `macos-14` runner | Upstream Electrobun | 1.3.5 | -| **x86_64** (Intel) | Binary swap from custom fork | `BitHighlander/electrobun` | 1.1.20 | +| **x86_64** (Intel) | Binary swap from published core artifact | `keepkey/keepkey-vault` release built from `blackboardsh/electrobun` | 1.3.9 | **Signing stays local.** CI builds unsigned artifacts. A developer with Apple credentials runs `make sign-release` to sign both architectures, create DMGs, notarize, and upload. -## Why a Custom Electrobun Fork for x64 +## Why a Published Electrobun x64 Core Artifact -Standard Electrobun x64 builds have two problems on older Intel Macs: +CI builds the macOS app on Apple Silicon, then swaps in a prebuilt x64 +Electrobun core artifact before packaging the Intel update payload. The artifact +is published on `keepkey/keepkey-vault` as `electrobun-x64-core-vN` and is built +from upstream `blackboardsh/electrobun`. -1. **resign-swizzle crash**: `libNativeWrapper.dylib` contains `resignKeyWindow` method swizzling that crashes on macOS 12 when the app loses focus -2. **Bun version**: Bun 1.3.x dropped macOS 12 support; Bun 1.1.20 is the last compatible version - -The fork (`BitHighlander/electrobun @ v1.16.1-keepkey.1`) provides pre-built x64 core binaries without resign-swizzle and with Bun 1.1.20. +When `modules/electrobun` changes, rebuild and republish the x64 core with +`make publish-electrobun-x64-core`, then update `X64_CORE_TAG` in +`.github/workflows/build.yml`. ## The Entitlements Requirement @@ -31,7 +33,11 @@ The entitlements file (`projects/keepkey-vault/entitlements.plist`) contains: - `allow-unsigned-executable-memory` — dynamic code execution - `disable-library-validation` — load unsigned dylibs - `allow-dyld-environment-variables` — runtime environment control -- `device.camera` — QR code scanning + +Do not add `com.apple.security.device.camera` here. Camera permission for QR +scanning is handled by `NSCameraUsageDescription` in `Info.plist`; adding the +sandbox camera entitlement to this Developer ID app makes the entitlement blob +invalid, and macOS ignores the whole blob. ## Three Signing Paths @@ -87,7 +93,7 @@ CI runs on push to `develop`, `release/*`, or `v*` tags. - Produces unsigned `stable-macos-arm64-keepkey-vault.app.tar.zst` ### x64 Variant (Binary Swap) -- Downloads pre-built x64 core from `BitHighlander/electrobun @ v1.16.1-keepkey.1` +- Downloads pre-built x64 core from `keepkey/keepkey-vault @ electrobun-x64-core-vN` - Extracts arm64 .app, swaps 4 binaries: `launcher`, `bun`, `libNativeWrapper.dylib`, `libasar.dylib` - Removes `zcash-cli` (Zcash shielded not supported on Intel) - Verifies all swapped binaries are x86_64 @@ -134,10 +140,10 @@ spctl --assess --type execute -vvv path/to/keepkey-vault.app **Cause**: Quarantine flag or missing notarization. **Fix**: Right-click → Open, or: `xattr -cr /path/to/app.dmg` -### x64 app crashes on macOS 12 +### x64 app has stale or swizzled runtime binaries **Cause**: `libNativeWrapper.dylib` has resign-swizzle symbols. **Verify**: `nm .../libNativeWrapper.dylib | grep resignKeyWindow` (should find nothing) -**Fix**: Rebuild x64 core from fork: `make build-electrobun-x64-core` +**Fix**: Rebuild and publish the x64 core artifact: `make publish-electrobun-x64-core` ## Building the x64 Electrobun Core diff --git a/docs/WINDOWS-1.3.2-LAUNCH-RETRO.md b/docs/WINDOWS-1.3.2-LAUNCH-RETRO.md new file mode 100644 index 00000000..98ef77c1 --- /dev/null +++ b/docs/WINDOWS-1.3.2-LAUNCH-RETRO.md @@ -0,0 +1,295 @@ +# Windows 1.3.2 Launch Retro and Next Debug Plan + +Date: 2026-05-12 +Branch target: `release/1.3.2` +Status: build pipeline improved, installer builds and signs, installed app still fails the fresh launch smoke. + +This document captures the Windows build/debugging session so the next batch can start from evidence instead of memory. The goal of the session was to produce a signed Windows installer, install it locally, open it, smoke test launch/runtime logs, then upload to the GitHub release. We did not reach release upload because the installed app still does not produce a fresh backend session. + +## Executive summary + +We successfully hardened several release-build failure points: + +- Zig 0.15.x pin and fast install path were established. +- Windows preflight script was added and exercised. +- `collect-externals.ts` no longer shells out to Unix `du`. +- Electrobun patching works under Git Bash GNU `sed`. +- Production build mirrors `_build/_ext_modules` into `Resources/app/node_modules` with `robocopy`. +- Signing now retries stale malformed PE certificate-table entries. +- `version.json` is forcibly patched to the package version and `stable` channel. +- The wrapper can launch the build-tree app directly through `bun.exe Resources/app/bun/index.js`. +- Installer build completes and signs artifacts. + +The remaining blocker is launch after install. The installed `KeepKeyVault.exe` exits quickly, no installed child process remains, and `vault-backend.log` does not advance. One direct installed backend probe before the final rebuild showed a missing nested WalletConnect dependency: + +```text +Cannot find module './cjs/src/concat.js' +from ...\@walletconnect\sign-client\node_modules\@walletconnect\core\node_modules\@walletconnect\relay-auth\node_modules\uint8arrays\concat +``` + +After that, `collect-externals.ts` was patched to preserve unreadable/deep nested `node_modules` trees on Windows and a full rebuild completed. The final direct installed backend probe was interrupted before producing a result, so the next session must establish whether the remaining failure is still packaging or has moved back to the wrapper/process launch path. + +## What changed in this session + +### Build docs and preflight + +Added: + +- `docs/WINDOWS-BUILD-QUIRKS.md` +- `docs/WINDOWS-PERFORMANCE-RECOVERY.md` +- `scripts/preflight-windows.ps1` + +The quirks doc records the major Windows release hazards encountered across sessions. The performance doc records the regression from the historical pre-bundled baseline to the current high-file-count installer. The preflight script validates common release-machine prerequisites before running `build-windows-production.ps1`. + +Preflight fixes made during the session: + +- Force scalar-or-array PowerShell values to arrays under StrictMode. +- Guard `.Trim()` calls on nullable git config output. +- Normalize several `.sh` files to LF because Git Bash/bash scripts fail with CRLF. + +### Windows build script + +Changed `scripts/build-windows-production.ps1` to: + +- Clear malformed/stale PE Security Directory entries before retrying `signtool`. +- Pin wrapper compilation to Zig 0.15.x and prefer `C:\Users\\tools\zig-x86_64-windows-0.15.1\zig.exe`. +- Mirror `_build/_ext_modules` into the final app bundle with `robocopy /MIR`. +- Force `Resources/version.json` to the package version, stable channel, and current backend hash using BOM-free UTF-8. +- Use short-path staging at `C:\tmp\kk` before Inno Setup. +- Re-sign wrapper and launcher after icon mutation with `rcedit`. + +### Electrobun patch script + +Changed `projects/keepkey-vault/scripts/patch-electrobun.sh` to use a GNU/BSD-compatible `sed_in_place` helper instead of BSD-only `sed -i ''`. + +### External dependency collection + +Changed `projects/keepkey-vault/scripts/collect-externals.ts` to: + +- Replace Unix `du` calls with in-process directory size calculation. +- Keep same-version nested packages on Windows instead of deleting them, because long-path deletion can leave partial packages that shadow valid top-level packages. +- Preserve unreadable nested `node_modules` on Windows instead of deleting them. + +The current theory is that over-pruning deep nested `node_modules` caused partial WalletConnect/uint8arrays trees to be packaged. + +### Wrapper launcher + +Changed `scripts/wrapper-launcher.zig` so the wrapper launches: + +```text +bin\bun.exe Resources\app\bun\index.js +``` + +directly instead of: + +```text +bin\launcher.exe +``` + +The build-tree wrapper smoke showed this direct path can spawn Bun and advance `vault-backend.log`. The installed wrapper still exited quickly, so the installed app failure is not yet proven fixed. + +## Evidence and baselines + +### Known good + +- Full production build completed after the final `collect-externals.ts` patch. +- Inno Setup completed. +- Installer installed silently with exit code `0`. +- Installed `Resources/version.json` reads: + +```json +{"version":"1.3.2","hash":"7121da4295c3fd6e","channel":"stable","baseUrl":"https://github.com/keepkey/keepkey-vault/releases/latest/download","name":"keepkey-vault","identifier":"com.keepkey.vault"} +``` + +- Earlier artifact/signature verification, before the final rebuild, showed valid Authenticode signatures for: + - `release-windows\KeepKey-Vault-1.3.2-win-x64-setup.exe` + - build-tree `KeepKeyVault.exe` + - build-tree `bin\launcher.exe` + +### Known bad + +- Installed `KeepKeyVault.exe` launch returns a PID, then exits. +- No installed process tree remains after waiting. +- `vault-backend.log` does not advance on installed wrapper launch. +- Before the final rebuild, direct installed `bun.exe Resources\app\bun\index.js` failed on a missing nested `uint8arrays\cjs\src\concat.js`. + +### Unknown after final rebuild + +The final direct installed backend probe was interrupted. The next session must answer: + +- Does the installed nested file exist now? +- Does direct installed `bun.exe "Resources\app\bun\index.js"` start and write a fresh backend log? +- If direct Bun works, why does `KeepKeyVault.exe` exit? +- If direct Bun fails, what is the first missing module or runtime error? + +## Next-session milestones + +### Milestone 1: freeze the current artifact facts + +Run: + +```powershell +$repo = "C:\Users\Matheus Louzada\kk\keepkey-vault" +Set-Location $repo +Get-ChildItem .\release-windows | Select Name,Length,LastWriteTime +Get-Content .\release-windows\SHA256SUMS.txt +Get-FileHash .\release-windows\KeepKey-Vault-1.3.2-win-x64-setup.exe -Algorithm SHA256 +Get-AuthenticodeSignature .\release-windows\KeepKey-Vault-1.3.2-win-x64-setup.exe +``` + +Success criteria: + +- Installer exists. +- Hash matches `SHA256SUMS.txt`. +- Installer signature is `Valid`. + +### Milestone 2: verify installed dependency completeness + +Check the exact previously missing path: + +```powershell +$install = Join-Path $env:LOCALAPPDATA "Programs\KeepKeyVault" +$missing = Join-Path $install "Resources\app\node_modules\@walletconnect\sign-client\node_modules\@walletconnect\core\node_modules\@walletconnect\relay-auth\node_modules\uint8arrays\cjs\src\concat.js" +Test-Path $missing +``` + +If it is missing, the current `collect-externals.ts` fix is insufficient or Inno Setup/short staging is still dropping deep files. + +### Milestone 3: direct installed backend test + +Run direct Bun with stderr capture and quote the app path: + +```powershell +$install = Join-Path $env:LOCALAPPDATA "Programs\KeepKeyVault" +$bun = Join-Path $install "bin\bun.exe" +$index = Join-Path $install "Resources\app\bun\index.js" +$log = Join-Path $env:LOCALAPPDATA "com.keepkey.vault\vault-backend.log" +$stdout = Join-Path $env:TEMP "kk-installed-direct-stdout.txt" +$stderr = Join-Path $env:TEMP "kk-installed-direct-stderr.txt" +$before = if (Test-Path $log) { (Get-Item $log).LastWriteTimeUtc } else { [datetime]::MinValue } +Remove-Item -Force $stdout,$stderr -ErrorAction SilentlyContinue +$p = Start-Process -FilePath $bun -ArgumentList ('"' + $index + '"') -WorkingDirectory (Join-Path $install "bin") -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru +Start-Sleep -Seconds 20 +$alive = $null -ne (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) +$after = if (Test-Path $log) { (Get-Item $log).LastWriteTimeUtc } else { [datetime]::MinValue } +"AliveAfter20s=$alive LogAdvanced=$($after -gt $before)" +Get-Content $stderr -Tail 80 +Get-Content $log -Tail 80 +if ($alive) { Stop-Process -Id $p.Id -Force } +``` + +Success criteria: + +- Log advances with a fresh `=== New session`. +- `argv` shows installed `bin\bun.exe` and installed `Resources\app\bun\index.js`. +- Boot reaches at least `[PERF] ... creating BrowserWindow`; ideal is `KeepKey Vault started!`. + +### Milestone 4: wrapper installed smoke + +Only after direct Bun works: + +```powershell +$install = Join-Path $env:LOCALAPPDATA "Programs\KeepKeyVault" +$exe = Join-Path $install "KeepKeyVault.exe" +$log = Join-Path $env:LOCALAPPDATA "com.keepkey.vault\vault-backend.log" +$before = (Get-Item $log).LastWriteTimeUtc +$p = Start-Process -FilePath $exe -PassThru +Start-Sleep -Seconds 35 +Get-CimInstance Win32_Process | + Where-Object { $_.ExecutablePath -like "$install\*" } | + Select-Object ProcessId,ParentProcessId,Name,CommandLine +$after = (Get-Item $log).LastWriteTimeUtc +"WrapperPid=$($p.Id) LogAdvanced=$($after -gt $before)" +Get-Content $log -Tail 100 +``` + +Success criteria: + +- `KeepKeyVault.exe` or child `bun.exe` remains alive long enough to show in the process tree. +- Log advances. +- No `launcher.exe -> Resources\main.js -> Worker` path is required. + +### Milestone 5: only then upload release asset + +Do not upload to GitHub Releases until: + +- Final installer hash/signature is verified. +- Silent install exits `0`. +- Direct installed backend passes. +- Wrapper installed smoke passes. +- User confirms the visible app window is acceptable. + +## Working theories to test next + +## Follow-up finding: 2026-05-12 + +The launch blocker was split across two layers: + +1. `collect-externals.ts` did collect the missing WalletConnect dependency into `_build\_ext_modules`, but the long build-tree destination path caused the deep nested file to be absent from `Resources\app\node_modules` and from the installed app. + - Missing file: + `@walletconnect\sign-client\node_modules\@walletconnect\core\node_modules\@walletconnect\relay-auth\node_modules\uint8arrays\cjs\src\concat.js` + - Direct installed Bun stderr before the manual overlay: + `Cannot find module './cjs/src/concat.js' from ...\uint8arrays\concat` + - Manual `robocopy _build\_ext_modules -> installed Resources\app\node_modules` fixed that crash. + +2. The direct `bun.exe Resources\app\bun\index.js` wrapper path got past module resolution after the overlay, but stalled at `new BrowserWindow(...)`. The stock Electrobun launcher path did not stall. + - Working process tree: + `KeepKeyVault.exe -> bin\launcher.exe -> bin\bun.exe ..\Resources\main.js` + - Working backend log reached: + `window created`, `boot complete`, and `KeepKey Vault started!` + +The production fix is: + +- Keep the no-spaces install directory: `KeepKeyVault`. +- Use the wrapper to start `bin\launcher.exe`, not direct Bun. +- Overlay `_build\_ext_modules` into `C:\tmp\kk\Resources\app\node_modules` after short staging and before Inno Setup. This bypasses the long build-tree destination path and gives Inno a complete short-path source tree. +- Add a short-stage probe for the exact WalletConnect file so future builds fail before packaging if the deep dependency is missing. + +Verified local smoke after manually copying the complete externals into the installed tree: + +- `bin\launcher.exe` launched. +- Child `bun.exe` ran `Resources\main.js`. +- Window title appeared as `KeepKey Vault v1.3.2`. +- `vault-backend.log` advanced through `KeepKey Vault started!`. + +Verified clean wrapper smoke after removing temporary diagnostics: + +- `KeepKeyVault.exe` launched `bin\launcher.exe`. +- `launcher.exe` launched `bin\bun.exe ..\Resources\main.js`. +- `DebugLogExists=False`, confirming the temporary wrapper recorder was not present. +- Window title appeared as `KeepKey Vault v1.3.2`. +- `vault-backend.log` advanced through `window created`, `boot complete`, and `KeepKey Vault started!`. +- The connected KeepKey was detected and reached `State -> ready`. + +Remaining release gate: + +- Run a fresh production build with the short-stage overlay in `build-windows-production.ps1`. +- Install that newly produced installer into a clean install tree. +- Confirm the WalletConnect probe exists in the installed tree. +- Run wrapper smoke from `KeepKeyVault.exe`, not `launcher.exe` directly. +- Verify final installer hash/signature before upload. + +1. Packaging still drops deep nested WalletConnect files. + - Evidence: direct installed backend previously failed on nested `uint8arrays`. + - Test: exact `Test-Path` plus direct backend stderr. + +2. Wrapper direct launch command is still subtly wrong after install. + - Evidence: build-tree wrapper advanced logs, installed wrapper did not. + - Test: direct Bun first; then compare wrapper-created process command line. + +3. Process starts but exits before logging because stderr is hidden. + - Evidence: wrapper launches hidden with `CREATE_NO_WINDOW`; no stderr capture. + - Test: temporarily add wrapper failure logging to `%LOCALAPPDATA%\com.keepkey.vault\wrapper-launch.log` or launch Bun through a tiny PowerShell/cmd shim for one diagnostic build. + +4. Inno Setup or short staging misses paths despite `robocopy`. + - Evidence: staged file count is high but may not prove the exact nested file exists in the installer payload. + - Test: compare `C:\tmp\kk`, build tree, and installed tree for the exact missing file. + +## Guardrails for next debugging batch + +- Do not upload a release asset until installed wrapper smoke passes. +- Do not use broad process-kill filters against `CommandLine`; they can kill the diagnostic PowerShell. Filter by `ExecutablePath -like "$install\*"`. +- Keep `C:\tmp\kk` staging until artifact inspection is complete. +- Treat `vault-backend.log` as the source of truth for app boot. +- Preserve direct Bun and wrapper tests as separate milestones. +- If adding wrapper diagnostics, make them append-only and remove or gate them before release. diff --git a/docs/WINDOWS-BUILD-AND-SIGN.md b/docs/WINDOWS-BUILD-AND-SIGN.md new file mode 100644 index 00000000..473be1ea --- /dev/null +++ b/docs/WINDOWS-BUILD-AND-SIGN.md @@ -0,0 +1,266 @@ +# Windows Build & Signing SOP + +The linear procedure for cutting a signed KeepKey Vault Windows release. Pairs with [`WINDOWS-DEV-SETUP.md`](./WINDOWS-DEV-SETUP.md) (first-time machine setup), [`WINDOWS-BUILD-QUIRKS.md`](./WINDOWS-BUILD-QUIRKS.md) (build/sign/package gotchas), and [`WINDOWS-QUIRKS.md`](./WINDOWS-QUIRKS.md) (runtime gotchas). + +The build, signing, and installer steps are all driven by **one PowerShell script**: [`scripts/build-windows-production.ps1`](../scripts/build-windows-production.ps1). This document explains what the script does, what to verify, and how to recover from common failures. + +--- + +## TL;DR + +```powershell +# From the repo root (PowerShell 5.1+), USB EV signing token plugged in: +.\scripts\preflight-windows.ps1 -Strict +.\scripts\build-windows-production.ps1 +``` + +Output: `release-windows\KeepKey-Vault--win-x64-setup.exe` (signed) and `SHA256SUMS-windows.txt`. + +To rebuild the installer from an existing build tree without rebuilding sources: + +```powershell +.\scripts\build-windows-production.ps1 -SkipBuild +``` + +To produce an unsigned test build (no token required): + +```powershell +.\scripts\build-windows-production.ps1 -SkipSign +``` + +--- + +## Prerequisites + +A fully provisioned build machine. If this is a fresh box, follow [`WINDOWS-DEV-SETUP.md`](./WINDOWS-DEV-SETUP.md) first. + +**Tools** (all must be on PATH or in their standard install location): +- Bun, Yarn, Git +- Windows SDK (provides `signtool.exe`) +- Inno Setup 6 (provides `ISCC.exe`) +- Zig compiler (`winget install zig.zig`) — builds the launcher wrapper EXE +- Rust + cargo — builds the `zcash-cli` sidecar +- `cmd.exe` and `robocopy` — present on every Windows install + +**Signing artifacts**: +- USB EV code-signing token plugged in and unlocked (PIN entered at least once in this session — the script triggers signtool which prompts when needed) +- Certificate visible in either `Cert:\CurrentUser\My` or `Cert:\LocalMachine\My` +- Default thumbprint baked into the script is the KEY HODLERS LLC EV cert (`986AEBA61CF6616393E74D8CBD3A09E836213BAA`). To use a different cert, set `$env:KK_SIGN_THUMBPRINT` or pass `-Thumbprint`. + +**Repo state**: +- Clean checkout on the release branch (e.g. `release/X.Y.Z`) with all expected submodules initialized — the script handles submodule init itself, but a dirty tree will produce a dirty build +- `modules/device-protocol/lib/messages_pb.js` **must exist** (see [The device-protocol pitfall](#the-device-protocol-pitfall) below) + +--- + +## What the script does (step by step) + +The script is one continuous flow. Each step exits hard on failure unless explicitly marked tolerant. Refer to line numbers in `scripts/build-windows-production.ps1` if you need to debug a specific stage. + +### 1. Pre-flight checks (lines ~205-252) +Verifies `signtool`, `ISCC`, `git`, `bun`, `yarn` are reachable. Loads the EV cert by thumbprint and warns if it expires in under 30 days. Fails fast with actionable messages if anything is missing. + +### 2. Submodule init (lines ~258-267) +Only initializes the modules the build actually needs: +- `modules/hdwallet` +- `modules/proto-tx-builder` +- `modules/device-protocol` + +`modules/keepkey-firmware` is not initialized by the Windows Vault build; it is emulator/firmware work only and is not a Vault packaging gate. + +### 3. device-protocol `lib/` verification (lines ~269-283) +Checks that `modules/device-protocol/lib/messages_pb.js` is present. If missing, the script aborts with instructions. This file is gitignored — see [The device-protocol pitfall](#the-device-protocol-pitfall). + +### 4. Dependency builds (lines ~285-304) +- `bun install` inside `modules/proto-tx-builder` +- `yarn install && yarn build` inside `modules/hdwallet` +- `bun install` inside `projects/keepkey-vault` (tolerates ENOENT on deeply nested `file:` deps — `collect-externals.ts` resolves these later) + +### 5. zcash-cli sidecar (lines ~306-316) +`cargo build --release` inside `projects/keepkey-vault/zcash-cli`. If the directory is missing, Zcash shielded features are silently disabled in the resulting build (non-fatal). + +### 6. Electrobun build (lines ~318-321) +`bun run build` — produces `_build/dev-win-x64/keepkey-vault-dev/`. Build channel is patched from `dev` to `stable` at runtime (line ~325) because Electrobun's native `--env=stable` produces a macOS-style bundle on Windows. + +### 7. Bulk signing (lines ~360-396) +Scans `*.exe`, `*.dll`, and `*.node` under `bin/` and `Resources/`; native `.node` addons and Bun `.bin` shims are skipped because they are not signable PE files. The wrapper EXE (`KeepKeyVault.exe`) is rebuilt and signed later in step 9. + +Skip patterns (treated as success): +- `.node` files — signtool doesn't support native addon binaries +- Files under `.bin\` — Bun shims with `.exe` extension are shell scripts, not real PE +- Files already validly signed (no double-sign) + +**Failure handling**: any unexpected signing failure aborts the run unless `-AllowSignFailures` is passed. Mixed signed/unsigned in a release would trigger SmartScreen on the unsigned binaries and break enterprise allowlists. + +### 8. Icon prep (lines ~402-462) +Converts the renamed-PNG `Resources/app.ico` to a real multi-size ICO (16/32/48/256px) using `System.Drawing`. `LoadImageW` at runtime can't load PNGs disguised as ICO, so this step is required for the title bar / taskbar icon. + +### 9. Build wrapper EXE + rcedit + re-sign (lines ~464-588) +- Compiles `scripts/wrapper-launcher.zig` to `KeepKeyVault.exe` via Zig (`-O ReleaseSmall --subsystem windows`) +- Copies the DPI manifest next to the wrapper +- Runs `rcedit` to embed the icon into the wrapper and `launcher.exe` +- **Re-signs** both rcedit-modified EXEs — `rcedit` invalidates Authenticode signatures because `BeginUpdateResource` modifies the `.rsrc` section. Without this, the user-launched binary ships unsigned. + +### 10. MAX_PATH staging (lines ~597-614) +The build tree has paths >260 chars (deep `node_modules`). Inno Setup silently skips such files. Workaround: use `robocopy` to stage everything into `C:\tmp\kk` first. Do not pass `/256`; that disables robocopy long-path support. + +### 11. Inno Setup compile + installer sign (lines ~616-657) +`ISCC` produces `release-windows\KeepKey-Vault--win-x64-setup.exe`, then `signtool` signs the installer itself. The WebView2 bootstrapper is bundled into the installer and runs at install time (required on Windows 10). + +### 12. Checksums (lines ~661-680) +Writes `release-windows\SHA256SUMS-windows.txt` covering every Windows artifact in the output directory. The CI draft-release job owns the combined `SHA256SUMS.txt`, so the Windows checksum file is platform-scoped to avoid clobbering it during manual upload. + +--- + +## The device-protocol pitfall + +`modules/device-protocol/lib/` is `.gitignore`d. The compiled protobuf files (`messages_pb.js`) only exist if built locally. **You cannot build `device-protocol` on Windows out of the box** — its `build:postprocess` step uses BSD `sed`, which is not present on Windows. + +The script fails fast with this message: + +``` +FATAL: modules/device-protocol/lib/messages_pb.js is MISSING +This file is gitignored and must be built before the Windows build runs. +``` + +**Three ways to get `lib/`**: + +1. **Build it on macOS / Linux**, then copy `modules/device-protocol/lib/` to the Windows machine. This is what the script docstring suggests. Works but is annoying. + +2. **Use WSL** on the same Windows box: + ```bash + wsl + cd /mnt/c/Users//kk/keepkey-vault/modules/device-protocol + npm install + npm run build + exit + ``` + The `lib/` directory is shared between WSL and Windows because of the `/mnt/c` path. + +3. **Use Git Bash** with a real `sed` (Git Bash ships GNU sed): + ```bash + cd modules/device-protocol + npm install + npm run build + ``` + Verify with `ls lib/messages_pb.js` afterward. This is the lightest-weight option and works on most setups. + +After the file exists once, subsequent builds reuse it. Commit-or-don't is a separate policy question — currently we don't commit it. + +--- + +## Verifying the release + +After the script finishes, verify everything before uploading: + +```powershell +$out = "release-windows" + +# 1. Installer is signed +$installer = Get-ChildItem "$out\*.exe" | Select-Object -First 1 +Get-AuthenticodeSignature $installer.FullName | Format-List +# Status should be: Valid + +# 2. The signature includes a timestamp counter-signature +signtool verify /pa /v $installer.FullName +# Look for "The signature is timestamped" + +# 3. SHA256SUMS-windows matches +Get-Content "$out\SHA256SUMS-windows.txt" +(Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLower() +# Should match +``` + +**End-to-end smoke**: +1. Run the installer on a fresh Windows VM (or a machine that's never had KeepKey Vault installed) +2. Confirm the install dir is `%LOCALAPPDATA%\Programs\KeepKeyVault\` with no spaces +3. Plug in a KeepKey and verify the splash advances to the dashboard +4. Open `%LOCALAPPDATA%\com.keepkey.vault\vault-backend.log` and confirm the `[Boot] platform=win32` line appears at the top (the sync logger from 1.2.14 — if it's missing, the worker died early) + +--- + +## Common failures + +### `signtool: 0x8009200D` or "no certificates found" +The USB EV token isn't reachable. Re-plug the token, unlock it with the management software (SafeNet / Sectigo Code Signing tool), then re-run. + +### `signtool: 0x80096004` or timestamp errors +DigiCert is rate-limiting or down. The script now retries Sectigo and GlobalSign automatically — if all three fail, wait 5 minutes and re-run. + +### `Inno Setup compilation failed` +Almost always a MAX_PATH issue. Inno silently drops files; the compile fails when a needed file is missing. Confirm `$ShortStage` was populated: + +```powershell +(Get-ChildItem -Recurse -File "C:\tmp\kk" | Measure-Object).Count +# Should be ~13,000+ files +``` + +If the count is low, robocopy hit a problem. Run it manually with full output: + +```powershell +robocopy "projects\keepkey-vault\_build\dev-win-x64\keepkey-vault-dev" "C:\tmp\kk" /E +``` + +### `FATAL: modules/device-protocol/lib/messages_pb.js is MISSING` +See [The device-protocol pitfall](#the-device-protocol-pitfall) above. + +### `Failed to compile wrapper EXE with Zig` +Zig not on PATH or not installed. `winget install zig.zig` and reopen the PowerShell session. + +### Wrapper EXE has wrong icon / "unknown publisher" +This used to happen because `rcedit` invalidated the wrapper signature after signing. The script now re-signs after rcedit. If you see it on a current build, the re-sign step (line ~575) failed silently — check the build log for `[ERROR] Failed to sign: KeepKeyVault.exe`. + +### App installs but shows no window +Most likely WebView2 is missing on Windows 10. The installer should have run the WebView2 bootstrapper (`MicrosoftEdgeWebview2Setup.exe`). Check `Add or Remove Programs` for "Microsoft Edge WebView2 Runtime"; if absent, run the bootstrapper manually. + +For deeper diagnosis read `%LOCALAPPDATA%\com.keepkey.vault\vault-backend.log` and `HANDOFF-1.2.14-WIN10-WATCHDOG-CRASH.md` (sync logger details). + +--- + +## Re-signing an existing installer + +If you need to re-sign a pre-built installer (cert rotation, missed timestamp, etc.): + +```powershell +$signtool = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe" +$thumb = "986AEBA61CF6616393E74D8CBD3A09E836213BAA" +$ts = "http://timestamp.digicert.com" + +& $signtool sign /sha1 $thumb /fd sha256 /tr $ts /td sha256 ` + /d "KeepKey Vault Installer" ` + "release-windows\KeepKey-Vault--win-x64-setup.exe" + +# Verify +& $signtool verify /pa /v "release-windows\KeepKey-Vault--win-x64-setup.exe" +``` + +--- + +## Script parameters reference + +| Param | Default | Purpose | +|---|---|---| +| `-SkipBuild` | off | Reuse existing `_build/`; only sign + package | +| `-SkipSign` | off | Build + package without signing (test builds) | +| `-Thumbprint` | hardcoded | Cert thumbprint override; also reads `$env:KK_SIGN_THUMBPRINT` | +| `-TimestampUrls` | digicert/sectigo/globalsign | First-success retry list; pass a custom array if your CA is different | +| `-OutputDir` | `release-windows` | Final artifact directory | +| `-AllowSignFailures` | off | Don't abort on signing errors (for iteration on a non-signing machine) | + +--- + +## Release checklist + +Before tagging and uploading: + +- [ ] Working tree is clean on the release branch (`git status` is empty) +- [ ] `package.json` version matches the intended release +- [ ] `modules/device-protocol/lib/messages_pb.js` is present +- [ ] EV token plugged in, unlocked, certificate visible +- [ ] Run `.\scripts\preflight-windows.ps1 -Strict` +- [ ] Run `.\scripts\build-windows-production.ps1` +- [ ] Verify installer signature via `signtool verify /pa /v` +- [ ] Smoke-test the installer on a clean Windows VM +- [ ] Compare `SHA256SUMS-windows.txt` against the installer hash +- [ ] Upload `*.exe` and `SHA256SUMS-windows.txt` to the GitHub draft release +- [ ] Run the installed app, pair a real device, confirm `vault-backend.log` has the expected boot lines diff --git a/docs/WINDOWS-BUILD-QUIRKS.md b/docs/WINDOWS-BUILD-QUIRKS.md new file mode 100644 index 00000000..6d29e257 --- /dev/null +++ b/docs/WINDOWS-BUILD-QUIRKS.md @@ -0,0 +1,818 @@ +# Windows Build & Sign Quirks — Complete Reference + +Every build/install/sign/package quirk we've encountered, with **symptom → root cause → diagnostic → fix → prevention**. Pairs with [`WINDOWS-QUIRKS.md`](./WINDOWS-QUIRKS.md) (runtime quirks), [`WINDOWS-BUILD-AND-SIGN.md`](./WINDOWS-BUILD-AND-SIGN.md) (release SOP), and [`WINDOWS-DEV-SETUP.md`](./WINDOWS-DEV-SETUP.md) (first-time machine setup). + +This document is **deliberately long**. The whole reason build sessions blow up is that quirks compound — you fix one, the next is hiding behind it. If you read this end-to-end before your next release, you can preflight all of them in under a minute and avoid the cascade. + +## How to use this doc + +- **In a release session, when something fails**: search for the error message verbatim. Every quirk lists the exact text you'll see. +- **Before a release session**: run `scripts/preflight-windows.ps1` (added in this session). It mechanizes the diagnostic from every quirk. +- **When adding a new dep / tool**: read the *Permanent prevention* row of any quirk that touches the same area. + +--- + +## Table of contents + +| # | Area | Quirk | +|---|---|---| +| 1 | Environment | [Zig version drift — wrapper-launcher pinned to 0.15.x](#1-zig-version-drift) | +| 2 | Environment | [Bun TLS `UNABLE_TO_VERIFY_LEAF_SIGNATURE` on some packages](#2-bun-tls-unable_to_verify_leaf_signature) | +| 3 | Environment | [Git `core.autocrlf=true` breaks bash scripts](#3-git-autocrlf-breaks-bash-scripts) | +| 4 | Environment | [PowerShell ExecutionPolicy](#4-powershell-executionpolicy) | +| 5 | Environment | [MAX_PATH / long path support](#5-max_path--long-path-support) | +| 6 | Environment | [WebView2 Runtime missing on Windows 10](#6-webview2-runtime-missing-on-windows-10) | +| 7 | Environment | [Working directory drift between bash and PowerShell](#7-working-directory-drift) | +| 8 | Build prerequisite | [`modules/device-protocol/lib/` is gitignored](#8-device-protocollib-gitignored) | +| 9 | Build | [Electrobun `bun run build` is incremental, not clean](#9-electrobun-build-is-incremental) | +| 10 | Build | [`version.json` doesn't auto-track `package.json`](#10-versionjson-doesnt-auto-track-packagejson) | +| 11 | Build | [`bun install` ENOENT on deeply nested `file:` deps](#11-bun-install-enoent-on-file-deps) | +| 12 | Build | [`postinstall` bash script with CRLF endings](#12-postinstall-bash-script-with-crlf) | +| 13 | Build | [Em-dashes in PowerShell string literals](#13-em-dashes-in-powershell-string-literals) | +| 14 | Build | [PowerShell 5 is default — no `&&` chaining, et al.](#14-powershell-5-is-default) | +| 15 | Build | [Pre-bundle backend regression — 393 → 14,800 files](#15-pre-bundle-backend-regression) | +| 16 | Sign | [Cert thumbprint pinning + env var override](#16-cert-thumbprint-pinning) | +| 17 | Sign | [signtool `0x800700C1 / BAD_EXE_FORMAT` on valid PE](#17-signtool-0x800700c1-bad_exe_format) | +| 18 | Sign | [rcedit invalidates Authenticode signatures](#18-rcedit-invalidates-authenticode-signatures) | +| 19 | Sign | [Cert can be in `CurrentUser\My` or `LocalMachine\My`](#19-cert-in-currentuser-vs-localmachine) | +| 20 | Sign | [Timestamp server flakiness](#20-timestamp-server-flakiness) | +| 21 | Sign | [`.node` and bun shims aren't signable PE files](#21-node-and-bun-shims-arent-signable-pe) | +| 22 | Sign | [Wrapper EXE doesn't exist during the bulk sign loop on clean builds](#22-wrapper-exe-not-signed-on-clean-builds) | +| 23 | Package | [Inno Setup silently drops files with paths >260 chars](#23-inno-setup-max_path-silent-drop) | +| 24 | Package | [`robocopy` default retry kills throughput with Defender](#24-robocopy-default-retry-policy) | +| 25 | Package | [`Expand-Archive` is dog-slow](#25-expand-archive-is-slow) | +| 26 | Package | [PowerShell 5 `-Encoding UTF8` writes BOM, breaks JSON](#26-powershell-5-utf8-bom) | +| 27 | Defender | [Real-time scan dominates build time](#27-defender-real-time-scan) | +| 28 | Install | [No-spaces install path](#28-no-spaces-install-path) | +| 29 | Install | [WebView2 must be bundled into the installer](#29-webview2-must-be-bundled) | +| 30 | Install | [Where to find logs when an install misbehaves](#30-where-to-find-logs) | + +--- + +## 1. Zig version drift + +**Symptom**: wrapper EXE compile fails with one of: +``` +error: root source file struct 'fs' has no member named 'selfExeDirPath' +error: root source file struct 'fs' has no member named 'cwd' +error: root source file struct 'time' has no member named 'milliTimestamp' +``` + +**Root cause**: `scripts/wrapper-launcher.zig` was last updated for **Zig 0.15.x** (commit `cfd6ea4`, 2026-03-21, "Zig 0.15.2 compat — DrawTextW sentinel slice to pointer cast"). Zig 0.16 (released ~Apr 2026) shipped the **IO context refactor** that removed: +- `std.fs.cwd()` → now requires `Io` context: `std.fs.cwd(io)` +- `std.time.milliTimestamp()` → relocated, requires `Io` +- `std.fs.selfExeDirPath` → removed entirely; moved to `std.process.executableDirPath(io, ...)` + +The build script auto-detects whichever `zig` is on PATH. Nothing in the repo pins a version. + +**Diagnostic**: +```powershell +zig version # 0.15.x → fine, 0.16.x → broken +``` + +**Fix**: install Zig 0.15.1 to `$env:USERPROFILE\tools\zig-x86_64-windows-0.15.1\`. The build script now checks this location first. +```powershell +Invoke-WebRequest -Uri "https://ziglang.org/download/0.15.1/zig-x86_64-windows-0.15.1.zip" ` + -OutFile "$env:USERPROFILE\tools\zig-0.15.1.zip" -UseBasicParsing +& "C:\Windows\System32\tar.exe" -xf "$env:USERPROFILE\tools\zig-0.15.1.zip" -C "$env:USERPROFILE\tools" +``` + +(Use `tar.exe` from `System32`, not PowerShell's `Expand-Archive` — see [quirk 25](#25-expand-archive-is-slow).) + +**Permanent prevention**: the build script now hard-fails if `zig version` doesn't start with `0.15.`. When Zig releases a new version, update the source and the pin together as a single commit, never piecemeal. + +--- + +## 2. Bun TLS `UNABLE_TO_VERIFY_LEAF_SIGNATURE` + +**Symptom**: `bun install` reports: +``` +error: UNABLE_TO_VERIFY_LEAF_SIGNATURE downloading package manifest @pioneer-platform/pioneer-discovery +error: UNABLE_TO_VERIFY_LEAF_SIGNATURE downloading package manifest @pioneer-platform/pioneer-client +error: UNABLE_TO_VERIFY_LEAF_SIGNATURE downloading package manifest bs58 +``` +Other packages download fine. Only a handful fail. + +**Root cause**: Corporate proxy / antivirus / Windows TLS inspection presents a different cert chain for specific CDN edges. Some npm packages route through CDNs whose certs Bun's bundled TLS stack doesn't trust. `~/.npmrc` may have `strict-ssl=false` — but **Bun does not honor `strict-ssl=false` from npmrc**. Bun has its own TLS stack and config. + +**Diagnostic**: +```powershell +# Check existing npmrc / bunfig state: +Get-Content "$env:USERPROFILE\.npmrc" -ErrorAction SilentlyContinue +Test-Path "$env:USERPROFILE\.bunfig.toml" + +# Reproduce in isolation: +cd projects\keepkey-vault +bun install +# Look for UNABLE_TO_VERIFY_LEAF_SIGNATURE lines +``` + +**Fix** (workaround for one install): +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = "0" +bun install +Remove-Item Env:\NODE_TLS_REJECT_UNAUTHORIZED +``` + +**Why this is acceptable**: if the user already has `strict-ssl=false` in `.npmrc`, TLS verification is already disabled for npm. This just matches that posture in Bun. + +**Permanent prevention**: install the corporate / AV root CA into Bun's trust store, OR add `bunfig.toml` with a CA bundle pointer once Bun ships proper TLS config. Until then, the workaround is fine for builds. + +--- + +## 3. Git autocrlf breaks bash scripts + +**Symptom**: `bun install`'s postinstall hook fails with: +``` +scripts/patch-electrobun.sh: line 33: syntax error near unexpected token `elif' +scripts/patch-electrobun.sh: line 33: ` elif grep -q ...' +error: postinstall script from "keepkey-vault" exited with 2 +``` + +**Root cause**: Git on Windows defaults to `core.autocrlf=true`, which converts LF → CRLF on checkout. Bash on Windows (Git-Bash) misparses certain shell constructs (`if/elif/fi`, here-docs, etc.) when lines end with CRLF. + +**Diagnostic**: +```bash +file projects/keepkey-vault/scripts/*.sh +# "with CRLF line terminators" → broken +# "ASCII text executable" → fine +``` + +**Fix** (single file): +```bash +sed -i 's/\r$//' projects/keepkey-vault/scripts/patch-electrobun.sh +``` + +**Permanent prevention**: add `.gitattributes` at repo root: +``` +*.sh text eol=lf +``` +This forces LF regardless of `core.autocrlf`. After committing the `.gitattributes`, do `git rm --cached && git add ` to renormalize each affected file. + +--- + +## 4. PowerShell ExecutionPolicy + +**Symptom**: `.ps1` script doesn't run at all, error similar to: +``` +File ... cannot be loaded because running scripts is disabled on this system. +``` + +**Root cause**: Windows default for current user is `Restricted`. Build/dev scripts are unsigned. + +**Fix**: +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Or invoke each script with `-ExecutionPolicy Bypass`: +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-windows-production.ps1 +``` + +(The npm scripts in `package.json` already use the `-ExecutionPolicy Bypass` form.) + +**Permanent prevention**: document in `WINDOWS-DEV-SETUP.md` as a first-time-machine step. Not changeable from inside the script (chicken-and-egg). + +--- + +## 5. MAX_PATH / long path support + +**Symptom**: variable. Specific failures: +- `git submodule update --init --recursive` errors or silently truncates +- `Inno Setup` silently drops files (see [quirk 23](#23-inno-setup-max_path-silent-drop)) +- `Copy-Item -Recurse` fails or skips files +- `Test-Path` returns false on a file that actually exists + +**Root cause**: Windows path API defaults to `MAX_PATH = 260` characters. Our `node_modules` has deeply nested `@walletconnect/...` and `@swagger-api/...` chains that exceed it. + +**Diagnostic**: +```powershell +# Check NTFS long-path policy (system-wide): +(Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name LongPathsEnabled).LongPathsEnabled +# 1 → enabled, 0 → disabled + +# Check git's long-path config: +git config --get core.longpaths +# true → enabled +``` + +**Fix** (one-time per machine, requires admin): +```powershell +New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` + -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force +git config --system core.longpaths true +# Reboot recommended. +``` + +**Permanent prevention**: documented in `WINDOWS-DEV-SETUP.md`. Not all individual tools (e.g. Inno Setup) honor long paths even when the OS does — see [quirk 23](#23-inno-setup-max_path-silent-drop) for the build-time workaround. + +--- + +## 6. WebView2 Runtime missing on Windows 10 + +**Symptom**: After install, double-clicking `KeepKeyVault.exe` produces **no window, no error, no log**. Process starts then exits silently. + +**Root cause**: The app renders its UI via Microsoft WebView2 (Edge Chromium runtime). Windows 11 ships it preinstalled. Windows 10 requires manual install. Without it, `CreateCoreWebView2EnvironmentWithOptions` returns an error that Electrobun handles by exiting silently. + +**Diagnostic**: +```powershell +Get-ItemProperty "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\*" -ErrorAction SilentlyContinue | + Where-Object { $_.name -match "WebView2" } +# Empty → not installed +``` + +**Fix**: bundled in the installer. `scripts/installer.iss` runs `MicrosoftEdgeWebview2Setup.exe /silent /install` during install. The build script downloads it from `https://go.microsoft.com/fwlink/p/?LinkId=2124703`. + +For manual install on Win10: +```powershell +$url = "https://go.microsoft.com/fwlink/p/?LinkId=2124703" +Invoke-WebRequest -Uri $url -OutFile "$env:TEMP\WebView2Setup.exe" -UseBasicParsing +& "$env:TEMP\WebView2Setup.exe" /silent /install +``` + +--- + +## 7. Working directory drift + +**Symptom**: `powershell -File scripts/build-windows-production.ps1` errors with: +``` +The argument 'scripts/build-windows-production.ps1' to the -File parameter does not exist. +``` +Even though the file exists in the repo. + +**Root cause**: The Bash tool persists working directory between commands. An earlier `cd projects/keepkey-vault` (e.g. for `bun install`) leaves the shell pointing there. The script path is relative to the repo root. + +**Diagnostic**: +```bash +pwd +# Should be /c/Users/.../keepkey-vault +ls scripts/build-windows-production.ps1 +``` + +**Fix**: Always anchor with an explicit `cd` to the repo root before invoking the script: +```bash +cd "/c/Users/Matheus Louzada/kk/keepkey-vault" && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-windows-production.ps1 +``` + +**Permanent prevention**: scripts that change directory should `Push-Location`/`Pop-Location` to restore on exit. The build script does this internally; the issue is only with the *invocation* from outside. + +--- + +## 8. device-protocol/lib gitignored + +**Symptom**: build script aborts with: +``` +FATAL: modules/device-protocol/lib/messages_pb.js is MISSING +``` + +**Root cause**: `modules/device-protocol/lib/` is in `.gitignore`. The compiled protobuf files (`messages_pb.js` and friends) only exist if you've built them locally. Building requires BSD `sed`, which is **not present on Windows**. + +**Diagnostic**: +```powershell +Test-Path "modules\device-protocol\lib\messages_pb.js" +``` + +**Fix**: three options, in order of preference: + +1. **Git Bash** (ships GNU sed which works fine): + ```bash + cd modules/device-protocol + npm install + npm run build + ls lib/messages_pb.js # should print + ``` + +2. **WSL**: + ```bash + wsl + cd /mnt/c/Users/.../keepkey-vault/modules/device-protocol + npm install && npm run build + ``` + +3. **Copy from another machine** where lib/ is built. + +After the first successful build the files persist; you don't need to redo this until someone deletes the submodule or `lib/`. + +**Permanent prevention**: see `docs/retro-windows-1.2.6.md`. Long-term fix would be to either commit `lib/` (controversial) or rewrite `build:postprocess` to not require BSD sed. + +--- + +## 9. Electrobun build is incremental + +**Symptom**: app installs with wrong version displayed (e.g. you're on `release/1.3.2` but installed app shows `1.2.16`). + +**Root cause**: `bun run build` calls `electrobun build` which is **incremental** — it doesn't clean `_build/` first. If you switched branches recently, the old branch's `_build/` artifacts persist. Some files (icons, the wrapper EXE) get overwritten; others (notably `Resources/version.json`) don't, because Electrobun decides they're "current." + +This bit us specifically: built on master at `1.2.16` days ago, then switched to `release/1.3.2`, ran the build script. Inno Setup happily packaged the stale 1.2.16 artifacts under a `1.3.2`-named installer EXE. + +**Diagnostic**: +```powershell +$build = "projects\keepkey-vault\_build\dev-win-x64\keepkey-vault-dev" +Get-ChildItem $build -File | Sort-Object LastWriteTime | Select-Object Name, LastWriteTime -First 5 +# Old timestamps mixed with new = stale state +Get-Content "$build\Resources\version.json" +# Compare version field to (Get-Content projects/keepkey-vault/package.json | ConvertFrom-Json).version +``` + +**Fix** (always, before a release build): +```powershell +Remove-Item -Recurse -Force projects\keepkey-vault\_build +``` + +**Permanent prevention**: the build script now (a) forces `version.json` to match `package.json` at patch time, and (b) **emits a warning** when they disagreed. Consider adding an unconditional `_build/` wipe at the top of the script when not `-SkipBuild`. + +--- + +## 10. version.json doesn't auto-track package.json + +**Symptom**: same as [quirk 9](#9-electrobun-build-is-incremental) — installed app reports the wrong version. + +**Root cause**: Electrobun writes `version.json` based on its own internal state; on incremental builds it doesn't always re-read `package.json`. The Vault runtime reads from `version.json`, not `package.json`. + +**Fix**: as of this session the build script always writes `package.json`'s version into `version.json`: +```powershell +$vj.version = $Version # $Version comes from package.json +``` +And warns loudly if they disagreed. + +**Permanent prevention**: never trust an Electrobun-emitted version field. Always force from `package.json`. This is now a script-enforced invariant. + +--- + +## 11. bun install ENOENT on file deps + +**Symptom**: `bun install` exits non-zero with hundreds of lines like: +``` +ENOENT: Failed to open node_modules folder for @cosmjs/socket in C:\Users\...\projects\keepkey-vault\node_modules\@keepkey/hdwallet-keepkey-nodehid\node_modules\@keepkey/hdwallet-keepkey\node_modules\@keepkey/proto-tx-builder\node_modules\@cosmjs/stargate\node_modules\@cosmjs/tendermint-rpc\node_modules +``` + +**Root cause**: Bun's handling of `file:`-linked workspace packages doesn't recurse correctly through deeply nested transitive deps. The packages reported as missing aren't actually needed at build time — `collect-externals.ts` walks deps separately and resolves them. + +**Diagnostic**: harmless if the build later succeeds. If `collect-externals` reports `Verified: all externals resolved`, ignore the ENOENT noise. + +**Fix**: tolerate. The build script already does: +```powershell +$ErrorActionPreference = 'Continue' +bun install +$ErrorActionPreference = 'Stop' +``` + +**Permanent prevention**: not really fixable from our side — it's a Bun limitation. Watch Bun release notes for `file:` link improvements. If we ever rip out the `@keepkey/*` `file:` deps and publish them, this goes away. + +--- + +## 12. postinstall bash script with CRLF + +See [quirk 3](#3-git-autocrlf-breaks-bash-scripts) — same root cause, but specifically about `projects/keepkey-vault/scripts/patch-electrobun.sh`, which runs as a npm `postinstall` hook after `bun install`. Failure surfaces only on Windows, and only on a fresh checkout where Git's `core.autocrlf` rewrote the LFs. + +--- + +## 13. Em-dashes in PowerShell string literals + +**Symptom**: +``` +Unexpected token 'the' in expression or statement. +ParserError: ... +``` +on lines that include an em-dash (`—`) inside a double-quoted string in a `.ps1` file. + +**Root cause**: PowerShell 5.1's parser tokenizes the contents of a double-quoted string at parse time, looking for variable interpolation and escape sequences. Em-dashes inside the string trip the tokenizer when the file is interpreted under certain encodings. + +Em-dashes **inside `#` comments are fine** — comments are skipped at the line level. + +**Diagnostic**: +```bash +grep -n "—" scripts/build-windows-production.ps1 +# Audit every match: is it inside a "string" or a # comment? +``` + +**Fix**: replace em-dashes inside string literals with ASCII `--`: +```powershell +# Bad: +throw "Cannot find version — ensure package.json is valid" +# Good: +throw "Cannot find version -- ensure package.json is valid" +``` + +**Permanent prevention**: this is a soft rule that's easy to break (Claude defaults to em-dashes in narrative writing). A linter check would catch it. Listed in [`retro-windows-1.2.6.md`](../projects/keepkey-vault/docs/retro-windows-1.2.6.md) as a recurring footgun. + +--- + +## 14. PowerShell 5 is default + +**Symptom**: scripts that work locally on PowerShell 7 fail on the user's machine, often silently or with strange parser errors. Patterns that don't exist in 5.1: +- `&&` and `||` chaining (`cmd1 && cmd2`) +- Ternary `?:` +- Null-coalescing `??` and `?.` +- `ConvertFrom-Json -AsHashtable` +- Some `Get-MpPreference` quirks + +**Root cause**: Windows ships PowerShell 5.1 (Windows PowerShell) by default. PowerShell 7+ ("pwsh") is a separate install. The build script targets 5.1 for compatibility. + +**Diagnostic**: +```powershell +$PSVersionTable.PSVersion +# 5.1.x → PowerShell 5, 7.x → pwsh +``` + +**Fix**: don't use post-5.1 features in build scripts. For chaining: +```powershell +# Bad: +git status && git diff +# Good: +git status; if ($?) { git diff } +``` + +**Permanent prevention**: keep scripts 5.1-compatible. Document at top of any `.ps1`: `# Targets PowerShell 5.1 — no &&, no ternary, no ??`. + +--- + +## 15. Pre-bundle backend regression + +**Symptom**: build is dog-slow at robocopy stage (~14,800 files to stage), Defender chews through them all. First-launch on Windows takes 30-56 seconds while Defender scans the 14k JS files. + +**Root cause**: commit `cc9181e` ("perf: pre-bundle backend — 13,400 files to 393") set up `bundle-backend.ts` to inline pure-JS deps into a single 6.5 MB `Resources/app/bun/index.js`, reducing the install to 393 files and first-launch to 2.1 seconds. + +Six subsequent fix commits had to **re-externalize** packages that Bun's bundler couldn't safely inline: +- `9d27f25` — google-protobuf (`jspb.Message` global pattern) +- `0905f58` — `@keepkey/proto-tx-builder` (submodule) +- `ac3b25d` — `swagger-client` + `@swagger-api/apidom-*` (`node:buffer` bug) +- `0d9a5f7` — `@walletconnect/*` (ESM/CJS dual-package resolution) +- `18552a6` — recursion in collect-externals pulled in more transitive deps +- `6776099` — more WalletConnect missing deps + +Each re-externalization brought a package + transitive deps back. Current count: ~14,800. + +**Diagnostic**: +```powershell +$build = "projects\keepkey-vault\_build\dev-win-x64\keepkey-vault-dev" +(Get-ChildItem -Recurse -File "$build\Resources\app\node_modules" | Measure-Object).Count +# Healthy: <500. Current: ~14,800. +``` + +**Fix**: see [`docs/WINDOWS-PERFORMANCE-RECOVERY.md`](./WINDOWS-PERFORMANCE-RECOVERY.md) for the full recovery plan. Not a single-line fix. + +**Permanent prevention**: add a CI file-count check on `Resources/app/node_modules/` with a regression threshold. Fail any PR that pushes the count above the threshold without justification. + +--- + +## 16. Cert thumbprint pinning + +**Symptom**: signing fails with: +``` +Certificate not found with thumbprint: +Make sure your USB signing token is connected. +``` + +Or, less obviously, **silently signs with the wrong cert** if a different cert with the same thumbprint exists. + +**Root cause**: the build script's default `-Thumbprint` value is the KEY HODLERS LLC EV cert (`986AEBA61CF6616393E74D8CBD3A09E836213BAA`). A new signer or a rotated cert won't match. + +**Security note**: a cert thumbprint is **not a secret** — it's a SHA-1 hash that identifies which cert in the local store to use. Signing requires the **private key**, which lives only on the physical USB EV token. Hardcoding a thumbprint in a public repo is fine. + +**Diagnostic**: +```powershell +Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My | + Select-Object Thumbprint, Subject, NotAfter | + Format-Table -AutoSize -Wrap +``` + +**Fix** (for a new signer): +```powershell +# Set the env var, then run as normal: +[Environment]::SetEnvironmentVariable("KK_SIGN_THUMBPRINT", "", "User") +# Reopen PowerShell so the env var loads. +.\scripts\build-windows-production.ps1 +``` + +**Permanent prevention**: env var override (added this session); document in `WINDOWS-BUILD-AND-SIGN.md`. + +--- + +## 17. signtool 0x800700C1 BAD_EXE_FORMAT + +**Symptom**: +``` +signtool.exe : SignTool Error: SignedCode::Sign returned error: 0x800700C1 +For more information, please see https://aka.ms/badexeformat +SignTool Error: An error occurred while attempting to sign: launcher.exe +``` +On a file that's clearly a valid PE (correct MZ header, PE signature, x64 machine type). + +**Root cause**: the PE has a **non-zero Security Directory entry** (cert table pointer in the Optional Header) pointing at malformed data — `Get-AuthenticodeSignature` reports `NotSigned`, but a stale ~10 KB cert-table-shaped chunk lives inside the file. signtool refuses to overwrite a damaged cert table. + +How does this happen? `rcedit` (the npm package we use to embed icons) modifies the PE resource section via `BeginUpdateResource`. That Win32 API doesn't strip or update the cert table — it just leaves the existing pointer in place. If the file was rcedit'd previously with a transient bad state, the cert table is now garbage. Also affects upstream Electrobun-built `launcher.exe` binaries in some configurations. + +**Diagnostic**: dump the Security Directory entry: +```powershell +$bytes = [System.IO.File]::ReadAllBytes($exe) +$peOff = [BitConverter]::ToInt32($bytes, 60) +$optOff = $peOff + 24 +$magic = [BitConverter]::ToUInt16($bytes, $optOff) +$rvaCountOff = if ($magic -eq 0x20B) { $optOff + 108 } else { $optOff + 92 } +$secDirOff = $rvaCountOff + 4 + (4 * 8) +$secDirRva = [BitConverter]::ToUInt32($bytes, $secDirOff) +$secDirSize = [BitConverter]::ToUInt32($bytes, $secDirOff + 4) +"Security Directory: RVA=$secDirRva Size=$secDirSize" +# Non-zero size + Get-AuthenticodeSignature says NotSigned → corrupt cert table +``` + +**Fix**: zero out the 8 bytes of the Security Directory entry in the Optional Header. The orphan cert-blob bytes elsewhere in the file are harmless (signtool will append a new entry). + +The build script's `Sign-File` function now does this automatically: on `0x800700C1`, calls `Clear-PECertTableEntry` and retries. + +Note: `signtool remove /s` does **not** work here (`0x00000057 / ERROR_INVALID_PARAMETER`) because the cert table is malformed, not validly-signed. + +**Permanent prevention**: strip-and-retry is built into `Sign-File`. Don't add code that touches the PE after signing; if you must, re-sign after. + +--- + +## 18. rcedit invalidates Authenticode signatures + +**Symptom**: after `rcedit ... --set-icon`, the previously-valid signature on the file is **invalid** (`Get-AuthenticodeSignature` reports `HashMismatch`). Or worse, the file ends up with a corrupt cert table per [quirk 17](#17-signtool-0x800700c1-bad_exe_format). + +**Root cause**: `rcedit` modifies the `.rsrc` section via `BeginUpdateResource`. Per Microsoft's docs ("If a signed PE file is modified, you may need to sign the file again so that it is recognized as a signed file"), this invalidates the Authenticode signature. + +This means **`rcedit` must run BEFORE signing, or the affected files must be re-signed AFTER `rcedit`**. + +**Diagnostic**: examine the order of operations in the build script: +```powershell +grep -nE "(Sign-File|rcedit|--set-icon)" scripts/build-windows-production.ps1 +``` + +**Fix** (current script flow): +1. Bulk sign loop (signs everything that exists at this point) +2. Build wrapper EXE (Zig) +3. rcedit on wrapper + launcher.exe (invalidates whatever sigs were there) +4. **Re-sign step** — re-signs the rcedit-modified files + +Until this session, step 4 was missing. Result: every prior release shipped with `launcher.exe` and `KeepKeyVault.exe` unsigned inside an otherwise-signed installer. SmartScreen would have warned every user. + +**Permanent prevention**: the re-sign step is now wired in. Never add another rcedit call without a matching re-sign. + +--- + +## 19. Cert in CurrentUser vs LocalMachine + +**Symptom**: build script reports "Certificate not found with thumbprint" even though you know it's installed. + +**Root cause**: EV USB tokens (SafeNet, Sectigo, etc.) install the certificate in different cert stores depending on driver mode and install context. Sometimes `Cert:\CurrentUser\My`, sometimes `Cert:\LocalMachine\My`. + +**Diagnostic**: the script already checks both. If it still can't find the cert: +```powershell +Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My | + Select-Object @{N='Store';E={ if ($_.PSParentPath -match 'CurrentUser') {'CurrentUser'} else {'LocalMachine'} }}, Thumbprint, Subject, NotAfter | + Format-Table -AutoSize -Wrap +``` +If your thumbprint isn't in either, the token isn't unlocked or the cert wasn't imported. + +**Fix**: unlock the token via the vendor's UI (SafeNet Authentication Client, Sectigo Code Signing tool), import the cert via vendor utility. + +--- + +## 20. Timestamp server flakiness + +**Symptom**: signing fails with: +``` +SignTool Error: The specified timestamp server either could not be reached or returned an invalid response. +``` +Or signed files end up *without* a timestamp counter-signature. + +**Root cause**: timestamp servers (digicert.com, sectigo.com, globalsign.com) periodically rate-limit or transiently fail. signtool returns an error and signing fails for that file. + +**Why it matters**: an untimestamped signature is valid only while the **cert** is valid. Once the cert expires (here: 2028-07-02), every untimestamped binary becomes "publisher unknown" forever. With a timestamp counter-signature, the binary's "signed at time T" anchor survives cert expiry. + +**Fix**: the build script now tries DigiCert → Sectigo → GlobalSign in sequence. Distinguishes real signing errors (cert/file problems) from transient timestamp errors. Retries on the latter, falls through on the former. + +**Permanent prevention**: monitor for `[RETRY]` lines in build output. If all three URLs fail consistently, file an issue — your network or AV is blocking all three. + +--- + +## 21. .node and bun shims aren't signable PE + +**Symptom**: in the sign loop: +``` +[SKIP] Native module (not signable): node-napi-v4.node +[SKIP] Native module (not signable): node.napi.node +[SKIP] Bun shim (not PE): pino.exe +``` + +**Root cause**: `.node` files are Node native addon binaries — they're DLL-ish but signtool doesn't recognize them as signable PE files. Bun shims in `node_modules/.bin/*.exe` are tiny shell scripts with a `.exe` extension, not real PE binaries (signtool returns `0x800700C1` — see [quirk 17](#17-signtool-0x800700c1-bad_exe_format), but this is a real PE format error, not a cert-table issue). + +**Diagnostic**: file the entry under "expected." If you see other files with the same skip message, that's [quirk 17](#17-signtool-0x800700c1-bad_exe_format). + +**Fix**: nothing to fix — these legitimately can't be signed. The build script handles both: +- `.node` extension → skip explicitly +- Path under `\.bin\` → skip explicitly +- True PE files that fail → strip cert table and retry (quirk 17 path) + +**Permanent prevention**: keep the skip list narrow — don't broaden it to mask real signing errors. The build script previously had `"not recognized"` as a skip trigger which was too loose; we tightened it this session. + +--- + +## 22. Wrapper EXE not signed on clean builds + +**Symptom**: on a freshly-cleaned `_build/`, the wrapper EXE (`KeepKeyVault.exe`) is the *user-launched* binary but is **unsigned** in the final installer. SmartScreen warns "unknown publisher" every launch. + +**Root cause**: the build script's sign loop runs at step N. It enumerates `bin/*.exe` and `*.dll` plus the wrapper at `_build/.../KeepKeyVault.exe`. **On a clean build, the wrapper doesn't exist yet** — it's compiled by Zig at step N+1. So the sign loop signs everything *except* the wrapper. The wrapper is then created, rcedit'd, and finally never signed at all. + +**Diagnostic**: +```powershell +Get-AuthenticodeSignature "$env:LOCALAPPDATA\Programs\KeepKeyVault\KeepKeyVault.exe" +# Status: NotSigned → bug +``` + +**Fix**: the build script now has an explicit re-sign step after rcedit ([quirk 18](#18-rcedit-invalidates-authenticode-signatures)) that signs both the wrapper and launcher. This is THE fix for the long-running silently-unsigned-wrapper bug. + +**Permanent prevention**: don't reorder these steps without testing. The current order is: bulk sign → icon → wrapper Zig build → rcedit → **re-sign wrapper + launcher** → installer. + +--- + +## 23. Inno Setup MAX_PATH silent drop + +**Symptom**: installer compiles "successfully" but the installed app crashes on first launch with `Cannot find module 'X'`. Module X exists in the source build tree. + +**Root cause**: Inno Setup's compiler doesn't enable long-path-aware Win32 APIs. Files whose *absolute source path* exceeds 260 chars are **silently skipped** from the output installer. No warning, no error in the ISCC log. + +The build tree has `_build/dev-win-x64/keepkey-vault-dev/Resources/app/node_modules/@walletconnect/.../node_modules/...` chains that exceed 260 chars. + +**Diagnostic**: +```powershell +# Before ISCC runs, sanity-check max source path: +Get-ChildItem -Recurse -File $BuildDir | + ForEach-Object { $_.FullName.Length } | + Sort-Object -Descending | + Select-Object -First 5 +# Anything > 250 is in danger zone +``` + +**Fix**: stage the entire build tree to a short prefix path before invoking ISCC. The build script does this with `robocopy → C:\tmp\kk`. After staging, every absolute path is `C:\tmp\kk\...` (8 char prefix) and well under 260. Do not pass `/256`; that disables robocopy long-path support. + +**Permanent prevention**: never invoke ISCC against the dev build tree directly. Always stage. The build script does this — don't disable it. + +--- + +## 24. robocopy default retry policy + +**Symptom**: `robocopy` for the Inno staging step hangs for 10+ minutes with no progress. Process is alive but CPU usage is ~0%. File count in destination stalls. + +**Root cause**: `robocopy`'s defaults are `/R:1000000 /W:30` — retry **one million** times with a 30-second wait between retries. When Windows Defender real-time scan briefly locks a file, robocopy doesn't time out; it waits forever. + +**Diagnostic**: +```powershell +Get-Process robocopy | Select-Object Id, CPU, WorkingSet, StartTime +# CPU should grow steadily; if stuck at ~1.5s after 10 minutes, you've hit the hang. +``` + +**Fix**: the script uses these flags: +``` +robocopy $src $dst /E /MT:16 /R:1 /W:1 /XJ /NFL /NDL /NJH /NJS /NP /NS +``` +- `/MT:16` — 16-thread parallel copy (~10x faster than single-threaded) +- `/R:1 /W:1` — retry ONCE with 1-second wait (don't get stuck on Defender locks) +- `/XJ` — skip junction points / reparse points (avoid symlink loops) +- `/NFL /NDL /NJH /NJS /NP /NS` — suppress per-file output + +Result: 14k files copied in ~5 seconds. + +**Permanent prevention**: these flags are committed. Don't remove them. + +--- + +## 25. Expand-Archive is slow + +**Symptom**: extracting a multi-tens-of-MB zip via PowerShell's `Expand-Archive` takes 5-10+ minutes. + +**Root cause**: PowerShell 5.1's `Expand-Archive` uses .NET's `ZipFile` class single-threaded with no I/O buffering. Defender real-time scans every file as it's written. For an 88 MB zig zip containing ~14k stdlib files, this can take 10+ minutes. + +**Fix**: use Windows' built-in `bsdtar` at `C:\Windows\System32\tar.exe` — it handles zip natively and is multi-threaded internally: +```powershell +& "C:\Windows\System32\tar.exe" -xf myarchive.zip -C destination/ +``` +Same archive: ~5 seconds instead of ~5 minutes. + +Do NOT use git-bash's `tar` — that's GNU tar which **cannot** read zip format. + +**Permanent prevention**: prefer `bsdtar` for any zip extraction in scripts. Faster, fewer dependencies. + +--- + +## 26. PowerShell 5 UTF8 BOM + +**Symptom**: app crashes silently on launch. `vault-backend.log` is empty or never created. SQLite never initializes. Settings never persist. + +**Root cause**: PowerShell 5.1's `Out-File -Encoding UTF8` writes a **BOM** (byte-order mark) at the start of the file. `version.json` with a BOM fails to parse in Bun's `require()`, which crashes `Electrobun.getVersionInfo()`, which throws inside `Utils.paths.userData`, which prevents the SQLite path from being computed. + +**Diagnostic**: +```powershell +$bytes = [System.IO.File]::ReadAllBytes("path\to\version.json")[0..2] +# BOM = 0xEF 0xBB 0xBF +$bytes | ForEach-Object { '{0:X2}' -f $_ } +``` + +**Fix** (in scripts that write JSON): +```powershell +[System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) +# The $false → no BOM +``` +NOT this: +```powershell +$content | Out-File -Encoding UTF8 -Path $path # writes BOM in PS 5.1 +``` + +**Permanent prevention**: when writing JSON / TOML / YAML, always use the explicit `WriteAllText` with `UTF8Encoding($false)`. Documented in `retro-windows-1.2.6.md`. + +--- + +## 27. Defender real-time scan + +**Symptom**: every build operation is 5-10x slower than on a Linux/Mac machine of similar specs. `robocopy`, `signtool`, `bun install`, `electrobun build` all bottleneck on Defender. + +**Root cause**: Windows Defender's real-time protection scans every file at write time and every executable at load time. With ~14,800 files in the build tree, this is the dominant build cost. + +**Fix** (one-time per machine, requires admin): +```powershell +Add-MpPreference -ExclusionPath "$env:USERPROFILE\path\to\keepkey-vault" +Add-MpPreference -ExclusionPath "C:\tmp\kk" +Add-MpPreference -ExclusionProcess "signtool.exe","robocopy.exe","bun.exe","node.exe","cargo.exe","ISCC.exe" +(Get-MpPreference).ExclusionPath +(Get-MpPreference).ExclusionProcess +``` + +**Security note**: build-machine local. These exclusions don't affect end-user installs — every user gets full Defender protection on the installed app. + +**Permanent prevention**: document in `WINDOWS-DEV-SETUP.md`, list as a strong recommendation for any build machine. + +--- + +## 28. No-spaces install path + +**Symptom**: app installs cleanly, launches, but Bun Worker processes fail to spawn. App is unresponsive. + +**Root cause**: Bun Workers silently fail when the executable path contains spaces. Default install of "KeepKey Vault" to `{autopf}\KeepKey Vault\` has a space and breaks. + +**Fix**: install dir is `KeepKeyVault` (no space). EXE is `KeepKeyVault.exe` (no space). Inno Setup config uses `{autopf}\KeepKeyVault` regardless of `MyAppName` display value. + +**Permanent prevention**: don't rename the install dir to include spaces. Documented in `installer.iss` comments. + +--- + +## 29. WebView2 must be bundled + +See [quirk 6](#6-webview2-runtime-missing-on-windows-10). Build-side: `MicrosoftEdgeWebview2Setup.exe` is downloaded by the build script and bundled into the installer via `installer.iss [Run]`. Don't strip it — Windows 10 users will silently fail to launch the app. + +--- + +## 30. Where to find logs + +When an installed build misbehaves, **`vault-backend.log` is the single source of truth**: + +```powershell +Get-Content "$env:LOCALAPPDATA\com.keepkey.vault\vault-backend.log" -Tail 100 +``` + +Other locations: +- `$env:LOCALAPPDATA\com.keepkey.vault\` — runtime data (logs, SQLite DB, WebView2 profiles) +- `$env:LOCALAPPDATA\Programs\KeepKeyVault\` — install dir (binaries, resources) +- `$env:LOCALAPPDATA\Programs\KeepKeyVault\Resources\version.json` — actual version of installed app + +**Note**: Windows has no separate Electrobun crash log, no WER dump in the usual locations, and renderer-side `console.log` doesn't reach the file. The boot env dump (sync logger, added in 1.2.14 — see `HANDOFF-1.2.14-WIN10-WATCHDOG-CRASH.md`) is at the top of every session's log lines. + +A session that ends abruptly mid-`Scanning for HID device...` is usually a native libusb crash (`HANDOFF-1.2.14-WINDOWS-PAIR.md` finding 2). + +--- + +## Preflight checklist + +Before any release build, run through this list. The `preflight-windows.ps1` script (added this session) mechanizes most of it: + +- [ ] Branch is the release branch you intend (`git rev-parse --abbrev-ref HEAD`) +- [ ] `package.json` version matches the intended release +- [ ] Working tree clean (`git status` empty) +- [ ] `modules/device-protocol/lib/messages_pb.js` exists ([quirk 8](#8-device-protocollib-gitignored)) +- [ ] `_build/` is absent OR you intend to use it ([quirk 9](#9-electrobun-build-is-incremental)) +- [ ] `zig version` reports 0.15.x ([quirk 1](#1-zig-version-drift)) +- [ ] `bun --version` is recent (no specific pin yet) +- [ ] `~/.npmrc` has expected `strict-ssl=false` if behind corporate TLS inspection ([quirk 2](#2-bun-tls-unable_to_verify_leaf_signature)) +- [ ] All `.sh` scripts have LF line endings (`file projects/keepkey-vault/scripts/*.sh`) ([quirk 3](#3-git-autocrlf-breaks-bash-scripts)) +- [ ] Defender exclusions present (`(Get-MpPreference).ExclusionPath`) ([quirk 27](#27-defender-real-time-scan)) +- [ ] EV token unlocked, cert visible (`Get-ChildItem Cert:\CurrentUser\My | Where Thumbprint -eq `) ([quirk 19](#19-cert-in-currentuser-vs-localmachine)) +- [ ] Long paths enabled in registry + git config ([quirk 5](#5-max_path--long-path-support)) + +If any item fails, **fix it before kicking off the build**. The build is 15-20 minutes; fixing a preflight item is 1 minute. Always preflight. + +--- + +## Related docs + +| Doc | What it covers | +|---|---| +| [`WINDOWS-BUILD-AND-SIGN.md`](./WINDOWS-BUILD-AND-SIGN.md) | Release SOP — what to run, in what order | +| [`WINDOWS-DEV-SETUP.md`](./WINDOWS-DEV-SETUP.md) | First-time machine setup for external contributors | +| [`WINDOWS-DEV-MODE.md`](./WINDOWS-DEV-MODE.md) | Dev launch + HMR troubleshooting | +| [`WINDOWS-QUIRKS.md`](./WINDOWS-QUIRKS.md) | **Runtime** quirks (window drag, drop-through clicks, etc.) | +| [`WINDOWS-PERFORMANCE-RECOVERY.md`](./WINDOWS-PERFORMANCE-RECOVERY.md) | Plan to recover the 393-file pre-bundle optimization | +| [`HANDOFF-1.2.14-WINDOWS-PAIR.md`](./HANDOFF-1.2.14-WINDOWS-PAIR.md) | Three open Win10 pair-failure findings | +| [`HANDOFF-1.2.14-WIN10-WATCHDOG-CRASH.md`](./HANDOFF-1.2.14-WIN10-WATCHDOG-CRASH.md) | Sync logger story + boot env dump | +| [`retro-windows-1.2.6.md`](../projects/keepkey-vault/docs/retro-windows-1.2.6.md) | First Windows release retro | diff --git a/docs/WINDOWS-DEV-SETUP.md b/docs/WINDOWS-DEV-SETUP.md new file mode 100644 index 00000000..561fff24 --- /dev/null +++ b/docs/WINDOWS-DEV-SETUP.md @@ -0,0 +1,293 @@ +# Windows Dev Setup — From Zero + +First-time setup of a Windows machine to build and run KeepKey Vault locally. This is the "I just got a Windows box" doc — every package and registry change is listed explicitly. Read top to bottom. + +For day-to-day dev workflow once setup is done, see [`WINDOWS-DEV-MODE.md`](./WINDOWS-DEV-MODE.md). To cut a signed release, see [`WINDOWS-BUILD-AND-SIGN.md`](./WINDOWS-BUILD-AND-SIGN.md). For platform gotchas reference, see [`WINDOWS-QUIRKS.md`](./WINDOWS-QUIRKS.md). + +--- + +## What you'll have at the end + +A Windows machine that can: +- Clone `keepkey-vault` and all submodules +- Run `bun run dev:hmr:win` to launch a dev build with HMR +- Run `.\scripts\build-windows-production.ps1 -SkipSign` to produce an unsigned installer +- (Optionally) sign + ship a real release if you have access to the EV cert and token + +Estimated time: **60–90 minutes** on a fresh Windows 11 box with good internet. Most of that is downloads. + +--- + +## 0. Baseline requirements + +- **Windows 10 build 17763+ or Windows 11 x64**. Windows 10 needs WebView2 Runtime; Windows 11 has it pre-installed. +- **Administrator access** on the machine. Several installs and a registry tweak require it. +- **At least 30 GB free disk space**. The repo + submodules + `node_modules` + Rust target dirs add up. +- **A working `winget`**. Pre-installed on Win11 and modern Win10. If `winget --version` errors, install "App Installer" from the Microsoft Store first. + +> Open an **Administrator PowerShell** for the install steps. Right-click the PowerShell icon → "Run as administrator". You'll know it worked when the title bar says "Administrator: Windows PowerShell". + +--- + +## 1. Enable long paths + +Windows defaults to a 260-character path limit (MAX_PATH). The keepkey-vault repo has nested `node_modules` paths that blow past this. Submodule cloning will fail or silently truncate without long-path support. + +```powershell +# 1a. NTFS long paths (registry — system-wide, requires admin) +New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` + -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force | Out-Null + +# 1b. Git long paths +git config --system core.longpaths true +``` + +A reboot is recommended after step 1a — some processes cache the old policy. + +--- + +## 2. PowerShell execution policy + +The build/dev scripts are unsigned `.ps1` files. The default `Restricted` policy refuses to run them. + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Verify with `Get-ExecutionPolicy -Scope CurrentUser` — should print `RemoteSigned`. + +--- + +## 3. Core tools (via winget) + +Run each in order. Each command is independent — if one fails, fix it before moving on rather than chaining. + +```powershell +# Git for Windows — includes Git Bash, which we use later for the device-protocol build +winget install --id Git.Git -e + +# Node.js LTS — needed by some submodule build scripts (yarn / npm) +winget install --id OpenJS.NodeJS.LTS -e + +# Yarn classic — required by modules/hdwallet +winget install --id Yarn.Yarn -e + +# Bun runtime — the project's primary JS runtime +winget install --id Oven-sh.Bun -e + +# Rust + cargo — for zcash-cli sidecar +winget install --id Rustlang.Rustup -e +# After install, set the default toolchain: +rustup default stable + +# Zig compiler — builds the wrapper EXE (KeepKeyVault.exe) +winget install --id zig.zig -e + +# Visual Studio Build Tools — provides MSVC + Windows SDK, required by native node addons +# (usb, node-hid) and by the cargo build of zcash-cli's transitive C dependencies. +winget install --id Microsoft.VisualStudio.2022.BuildTools -e --override ` + "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" + +# Inno Setup 6 — produces the installer EXE (only needed for release builds) +winget install --id JRSoftware.InnoSetup -e +``` + +**Close and reopen your PowerShell session after this block** so `PATH` updates pick up. + +Verify everything is reachable: + +```powershell +git --version +node --version +yarn --version +bun --version +cargo --version +zig version +where.exe signtool.exe # should print a path under "Windows Kits\10\bin\..." +where.exe iscc # should print Inno Setup path +``` + +> If `signtool.exe` is not found, the VS Build Tools install did not include the Windows 10 SDK. Open the Visual Studio Installer GUI, modify the Build Tools install, and tick "Windows 10 SDK" or "Windows 11 SDK". + +--- + +## 4. WebView2 Runtime + +Windows 11 has this pre-installed. On Windows 10, verify: + +```powershell +Get-ItemProperty "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\*" ` + -ErrorAction SilentlyContinue | Where-Object { $_.name -match "WebView2" } +``` + +If nothing prints, install it: + +```powershell +$url = "https://go.microsoft.com/fwlink/p/?LinkId=2124703" +$out = "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" +Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing +& $out /silent /install +``` + +Without WebView2, KeepKey Vault launches but produces no window — see [`WINDOWS-QUIRKS.md`](./WINDOWS-QUIRKS.md) §13. + +--- + +## 5. Clone the repo + +```powershell +# Pick a short path — the deeper the checkout, the more MAX_PATH headroom you burn. +# C:\kk works; C:\Users\YourName\Documents\Projects\... probably won't. +mkdir C:\kk +cd C:\kk +git clone https://github.com/keepkey/keepkey-vault.git +cd keepkey-vault +``` + +Submodules are initialized selectively by the build script — **do not** run `git submodule update --init --recursive`. Firmware is emulator-only for Vault releases, and recursive firmware submodules have historically caused Windows path issues. + +--- + +## 6. Build the `device-protocol` library (one-time, the tricky bit) + +`modules/device-protocol/lib/` is gitignored. The compiled protobuf files (`messages_pb.js`) need to exist before anything else in the build can work. The `device-protocol` build uses BSD `sed` in its postprocess step which is not native to Windows. + +**Easiest path: use Git Bash**, which ships with GNU `sed`: + +```bash +# Open Git Bash (Start Menu → "Git Bash") +cd /c/kk/keepkey-vault +git submodule update --init modules/device-protocol +cd modules/device-protocol +npm install +npm run build + +# Verify +ls lib/messages_pb.js +# Should print: lib/messages_pb.js +``` + +Alternatives if Git Bash isn't available: +- **WSL**: `wsl` → `cd /mnt/c/kk/keepkey-vault/modules/device-protocol && npm install && npm run build` +- **Copy from another machine**: if a teammate has a built copy, copy the entire `modules/device-protocol/lib/` directory over. + +After this step, you can go back to PowerShell for the rest. + +--- + +## 7. First build — verify the toolchain works + +A signed release build needs the EV token, which most contributors won't have. Do an **unsigned test build** to confirm everything compiles: + +```powershell +.\scripts\build-windows-production.ps1 -SkipSign +``` + +What this should produce: +- ~15-20 minutes of output (submodule init, `bun install`, `yarn build`, `cargo build`, electrobun bundle, Inno Setup compile) +- `release-windows\KeepKey-Vault--win-x64-setup.exe` (unsigned) +- `release-windows\SHA256SUMS-windows.txt` + +If the script aborts, read the error message carefully — every failure mode has a specific message and most are listed in [`WINDOWS-BUILD-AND-SIGN.md`](./WINDOWS-BUILD-AND-SIGN.md#common-failures). The most common first-time issues: + +- **`FATAL: modules/device-protocol/lib/messages_pb.js is MISSING`** — you skipped step 6. Go back. +- **`Zig compiler not found`** — `winget install zig.zig` then reopen PowerShell. +- **`Inno Setup compilation failed`** — MAX_PATH issue. Re-run after confirming long paths are enabled (step 1). +- **`cargo build --release failed for zcash-cli`** — usually missing VS Build Tools C++ workload. Open Visual Studio Installer, modify the Build Tools install, and ensure "Desktop development with C++" is ticked. + +--- + +## 8. Run the dev mode + +Production builds are slow (~15 min). For day-to-day dev, use the HMR script: + +```powershell +cd C:\kk\keepkey-vault +bun run dev:hmr:win +``` + +This is documented in detail in [`WINDOWS-DEV-MODE.md`](./WINDOWS-DEV-MODE.md). Briefly: +- Kills stale Bun / launcher / electrobun processes +- Frees ports 5177 (Vite HMR) and 50000 (app REST) +- Builds, then launches `launcher.exe` directly from the dev build tree + +If the app starts but no window appears, see [`WINDOWS-DEV-MODE.md`](./WINDOWS-DEV-MODE.md) §2 — this is a known WebView2 non-determinism issue on Windows. + +--- + +## 9. Optional — set up code signing + +Only relevant if you'll be cutting releases. Most contributors skip this section. + +1. Plug in the USB EV signing token (e.g., SafeNet eToken, Sectigo USB token) +2. Install the vendor's certificate management software (typically bundled on the token's drive or downloadable from the vendor) +3. Unlock the token and import the certificate into the Windows cert store (the vendor tool walks you through this — the cert ends up in `Cert:\CurrentUser\My`) +4. Capture the thumbprint: + ```powershell + Get-ChildItem Cert:\CurrentUser\My | Format-Table Subject, Thumbprint, NotAfter + ``` +5. Set the thumbprint as an env var so you don't have to pass it every time: + ```powershell + [Environment]::SetEnvironmentVariable("KK_SIGN_THUMBPRINT", "", "User") + ``` + Reopen PowerShell. Verify with `$env:KK_SIGN_THUMBPRINT`. +6. Run a signed build: + ```powershell + .\scripts\build-windows-production.ps1 + ``` + +Full release workflow lives in [`WINDOWS-BUILD-AND-SIGN.md`](./WINDOWS-BUILD-AND-SIGN.md). + +--- + +## Common setup gotchas + +### `bun install` fails with ENOENT on deeply nested deps +Expected — Bun has issues with deeply nested `file:` workspace deps. The production build script tolerates this. If you're running `bun install` manually outside the script and want to verify success, check that `node_modules/electrobun/` and `node_modules/@keepkey/` exist; the ENOENT errors are on transitive deps that `collect-externals` handles later. + +### `yarn build` in `modules/hdwallet` fails with TypeScript errors +The hdwallet submodule pins specific TypeScript versions. If you have a globally-installed TypeScript that's incompatible, remove it: `npm uninstall -g typescript`. Yarn will use the workspace-local version. + +### `cargo build --release` is very slow on first run +~5-10 minutes is normal — Rust compiles a lot of crypto crates from scratch. Subsequent builds are fast (incremental). The target directory ends up around 3-4 GB. + +### `signtool` says "no certificates were found that met all the given criteria" +The token is plugged in but not unlocked, or the cert isn't visible in `Cert:\CurrentUser\My`. Re-run the vendor's cert management tool and ensure the cert is "installed for the current user." + +### Antivirus deletes `bun.exe` or `launcher.exe` +Bun's JIT triggers some heuristic AV scanners. Add an exclusion for `C:\kk\keepkey-vault\` and `%LOCALAPPDATA%\Programs\KeepKeyVault\` in your AV's settings. + +### `pnpm` vs `bun` vs `npm` +This project uses **Bun** as the primary runtime and package manager. `npm` and `yarn` are only used inside specific submodules. Do not switch the top-level project to npm/yarn — it will break the workspace resolution. + +--- + +## Quick reference: what each tool is for + +| Tool | Used by | What breaks without it | +|---|---|---| +| Bun | All of `projects/keepkey-vault` | Nothing builds | +| Yarn classic | `modules/hdwallet` | hdwallet build fails | +| Node + npm | `modules/device-protocol` | Can't build `lib/messages_pb.js` | +| Rust + cargo | `projects/keepkey-vault/zcash-cli` | Zcash shielded features missing | +| Zig | Wrapper EXE compile | `KeepKeyVault.exe` missing | +| VS Build Tools | `usb`, `node-hid`, cargo C deps | Native modules don't compile | +| Windows SDK | `signtool.exe` | Can't sign releases | +| Inno Setup 6 | `ISCC.exe` | Can't build installer | +| WebView2 Runtime | Runtime UI rendering | App has no window | +| Git Bash | `device-protocol` postprocess | `lib/` never builds | + +--- + +## Where things live on disk + +| Path | What | +|---|---| +| `C:\kk\keepkey-vault\` | Repo | +| `C:\kk\keepkey-vault\projects\keepkey-vault\_build\` | Dev build output | +| `C:\kk\keepkey-vault\release-windows\` | Signed installer output | +| `C:\tmp\kk\` | MAX_PATH staging area (auto-created during release build) | +| `%LOCALAPPDATA%\Programs\KeepKeyVault\` | Production install location | +| `%LOCALAPPDATA%\com.keepkey.vault\` | Runtime data: `vault-backend.log`, SQLite DB, WebView2 profiles | + +When troubleshooting an installed build, **`vault-backend.log` is the single source of truth** — see [`HANDOFF-1.2.14-WIN10-WATCHDOG-CRASH.md`](./HANDOFF-1.2.14-WIN10-WATCHDOG-CRASH.md) for the diagnostic story behind the synchronous logger. diff --git a/docs/WINDOWS-PERFORMANCE-RECOVERY.md b/docs/WINDOWS-PERFORMANCE-RECOVERY.md new file mode 100644 index 00000000..5c455b77 --- /dev/null +++ b/docs/WINDOWS-PERFORMANCE-RECOVERY.md @@ -0,0 +1,122 @@ +# Windows Performance: Pre-Bundle Recovery + +The Windows installer's first-launch performance depends on having as few files as possible inside `Resources/app/node_modules/`. Each file Windows Defender encounters on the first launch is scanned synchronously, and `bun.exe` triggers Defender for every file it `require()`s. The historical baseline was 13,400 files → **56 seconds** of Defender scan on first launch. + +Commit `cc9181e` (2026-03-22, "perf: pre-bundle backend — 13,400 files to 393, first launch 56s to 2.1s") solved this by inlining nearly all pure-JS dependencies into a single `Resources/app/bun/index.js`. That commit cut the install to 393 files and first-launch to 2.1 seconds. + +**That optimization has since eroded back to ~14,800 files.** This document explains why, what's recoverable, and the constraints for recovering it safely. + +--- + +## Current state + +Run a check before any recovery work: + +```powershell +$build = "projects\keepkey-vault\_build\dev-win-x64\keepkey-vault-dev" +(Get-ChildItem -Recurse -File $build | Measure-Object).Count +(Get-ChildItem -Recurse -File "$build\Resources\app\node_modules" | Measure-Object).Count +Get-ChildItem "$build\Resources\app\bun" | Select-Object Name, Length +``` + +Expected (post-recovery): total ~400-600 files, `node_modules` < 200 files, `Resources/app/bun/index.js` ≈ 6-16 MB. +Current (regressed): total ~14,800, `node_modules` ~14,800, `index.js` ≈ 6.5 MB. + +`bundle-backend.ts` is still running — `index.js` is being produced. What's regressed is `collect-externals.ts`'s externalization list. + +--- + +## Why it regressed + +After `cc9181e` landed, six follow-up commits had to re-externalize packages that Bun's bundler couldn't safely inline. Each re-externalization brought the package back into `node_modules/` along with its transitive deps: + +| Commit | Package(s) re-externalized | Root cause | +|---|---|---| +| `9d27f25` | `google-protobuf` | Bundler breaks `jspb.Message` methods (uses `this \|\| window` global pattern) | +| `0905f58` | `@keepkey/proto-tx-builder` | Depends on osmosis-frontend submodule for Cosmos proto codegen | +| `ac3b25d` | `swagger-client` (+ `@swagger-api/apidom-*`) | `export * from 'node:buffer'` resolves to `undefined` when bundled → Linux crash | +| `0d9a5f7` | `@walletconnect/*` | ESM/CJS dual-package resolution: auth-client imports `isBrowser`/`TYPE_1` from utils that aren't in CJS exports | +| `18552a6` | (no new entries — recursion fix) | `addNestedDeps` started walking nested `node_modules`, transitively pulling more deps | +| `6776099` | (more WC missing deps) | WalletConnect transitive deps were missing on first install | + +Each was a correct fix at the time. The combined effect is what we see today. + +### Where the file count is concentrated + +``` +@swagger-api 1,591 files ← biggest single contributor (apidom 3.0/3.1/3.2 + JSON Schema drafts) +@walletconnect 865 files ← 21 subpackages, ESM/CJS hell +@babel 800 files ← transitive +@cosmjs 386 files ← transitive of proto-tx-builder +@noble 145 files +@ethersproject 130 files +(180 other top-level packages) ~10,000 files +``` + +--- + +## What's recoverable now + +Bun's bundler has improved significantly since these fixes landed. Each re-externalization is worth re-evaluating, but **not all at once and not during a release**. + +### Highest impact, lowest risk + +1. **`google-protobuf`** — small (~50 files), single root cause (global `this`). Worth a targeted retry: wrap with a polyfill at bundle time, or try Bun's updated CJS handling. ~50 file reduction. + +2. **`@noble/*` and `@ethersproject/*`** — 275 files combined, all pure ESM, no native deps. These almost certainly bundle clean now but they may already be inlined; verify before counting them. + +### Medium impact, medium risk + +3. **`@walletconnect/*`** — 865 files. This is the highest-value chunk. The ESM/CJS issue was a Bun bundler limitation; check whether modern Bun resolves `auth-client → utils` correctly. Test with a real WalletConnect dApp pairing flow before relying on a bundled build. + +4. **`@cosmjs/*` (via `proto-tx-builder`)** — 386 files. `proto-tx-builder` itself can't be bundled (submodule build), but its transitive `@cosmjs/*` deps can be examined. + +### Largest gain, highest risk + +5. **`@swagger-api/*` + `swagger-client`** — 1,591 files, 4x more than the next-largest package. Two paths: + - Try bundling `swagger-client` again after fixing the `node:buffer` issue at bundle time (Bun plugin that rewrites `export * from 'node:buffer'`). + - **Or eliminate the dependency entirely** — `windows-performance-strategy.md` proposed replacing `@pioneer-platform/pioneer-client` (the sole consumer of `swagger-client`) with a thin hand-written fetch wrapper for the 5-6 Pioneer endpoints actually used. ~3,500 files / 16 MB saved. This is the cleanest long-term fix but requires real engineering work and Pioneer API stability assumptions. + +--- + +## Verification gates for any recovery attempt + +Each re-bundling must pass: + +1. **Cross-platform check** — Linux is the canary for `node:buffer`-style bundler bugs. macOS first-launch and Linux first-launch must both succeed. +2. **Cold install on Windows 10 + 11** — first-launch shouldn't show the `[Engine] Loaded bundled manifest` hang that `cc9181e` originally fixed. +3. **Full smoke** — pair a device, run a swap quote, complete a WalletConnect handshake, sign an EVM tx, sign a Cosmos tx, fetch a Bitcoin balance. The original regression bugs all surfaced as either silent crashes or wrong-data bugs, not as build-time errors. +4. **File count diff in CI** — every PR that touches `collect-externals.ts` or `bundle-backend.ts` should print a before/after file count. Regressions over 500 files are flagged for review. + +--- + +## What's recoverable on the build machine, separately + +Independent of bundle work, the **build machine's** Defender configuration significantly affects build time. Each file in the staging copy (`robocopy → C:\tmp\kk`) is real-time scanned. Adding the right exclusions cuts staging from minutes to seconds: + +```powershell +# Run in an Administrator PowerShell +Add-MpPreference -ExclusionPath "" +Add-MpPreference -ExclusionPath "C:\tmp\kk" +Add-MpPreference -ExclusionProcess "signtool.exe","robocopy.exe","bun.exe","node.exe","cargo.exe","ISCC.exe" +``` + +These exclusions are **build-machine local** — they have no effect on end-user installs. End users get full Defender protection on the installed app. + +--- + +## Suggested follow-up work + +1. **Open an issue** titled "Recover pre-bundle file count regression (14,800 → target ~500)" referencing this doc and `cc9181e`. +2. **Tackle in order**: google-protobuf → @noble/@ethersproject (verify status) → @walletconnect → swagger-client (or pioneer-client replacement). +3. **Land each retry as its own PR** with a Windows install + smoke test plan. Do not bundle multiple re-bundles into one change — when something breaks, we need a clean bisect target. +4. **Add a file-count check to CI** — fail the Windows build if `Resources/app/node_modules/` exceeds a configured threshold (start at 15,000, ratchet down as recoveries land). + +--- + +## Files + +- `projects/keepkey-vault/scripts/bundle-backend.ts` — the working pre-bundler +- `projects/keepkey-vault/scripts/collect-externals.ts` — where the re-externalization list lives (`FORCE_EXTERNAL` set) +- `projects/keepkey-vault/scripts/patch-bundle.ts` — post-build patches for known bundler bugs (e.g. `node_buffer`) +- `projects/keepkey-vault/docs/windows-performance-strategy.md` — original strategy doc (medium-term + long-term plans) diff --git a/docs/handoff-near-intents-btc-eth.md b/docs/handoff-near-intents-btc-eth.md new file mode 100644 index 00000000..08248811 --- /dev/null +++ b/docs/handoff-near-intents-btc-eth.md @@ -0,0 +1,205 @@ +# Handoff: BTC→ETH via ShapeShift NEAR Intents + +**Status**: Pioneer (blue) deployed ✅ — Vault changes committed locally, needs PR +**Branch**: `portfolio-debug` in keepkey-vault-v11 +**Pioneer branch**: `release/dedup-scam-filter` → api-blue.keepkey.info + +--- + +## What was fixed + +THORChain is globally halted (all 43 pools, `trading_halted: true`). MayaChain is down (502). +ShapeShift routes BTC→ETH via **NEAR Intents** — a memo-less deposit: just send BTC to a +deposit address, no OP_RETURN memo required. Three layers needed fixing: + +### 1. Pioneer: `shapeshift-swap` client (`modules/intergrations/shapeshift-swap/src/index.ts`) + +ShapeShift's NEAR Intents response puts the BTC deposit address in `step.allowanceContract`, +not `step.transactionData.to` (which is `{}`). The transfer branch now reads: + +``` +depositAddress = txData.to || step.allowanceContract || originalQuote.recipientAddress +txParams.to = depositAddress +txParams.recipientAddress = depositAddress +txParams.swapper = step.source // 'NEAR Intents' +``` + +### 2. Pioneer: router (`modules/pioneer/pioneer-router/src/index.ts`) + +- `hasInstructions` now accepts `transfer`-type txs that have a destination address (no memo required). +- `MEMOLESS_SUPPORT` now includes `shapeshiftSwap: true` so `memoless: true` requests route via NEAR Intents instead of returning "No quotes available". + +### 3. Vault: `swap-parsing.ts` + `swap.ts` + +Two memo-required guards each needed a NEAR Intents exemption: + +```ts +const isMemolessTransfer = !!inboundAddress && swapper === 'NEAR Intents' +``` + +Files changed: +- `projects/keepkey-vault/src/bun/swap-parsing.ts` — line ~185 +- `projects/keepkey-vault/src/bun/swap.ts` — lines ~401 and ~683 + +--- + +## How to test locally + +### Prerequisites + +- Pioneer running locally (`make start` in pioneer monorepo, port 9001) +- Vault running against local Pioneer (or use `--blue` flag in the e2e test to hit blue) +- BTC in the wallet (need ~0.001+ BTC + fees) +- KeepKey connected + +### 1. Verify Pioneer returns a NEAR Intents quote + +```bash +curl -s -X POST http://localhost:9001/api/v1/quote \ + -H "Content-Type: application/json" \ + -d '{ + "sellAsset": "bip122:000000000019d6689c085ae165831e93/slip44:0", + "sellAmount": "0.001", + "buyAsset": "eip155:1/slip44:60", + "recipientAddress": "0xYOUR_ETH_ADDRESS", + "senderAddress": "YOUR_BTC_ADDRESS" + }' | python3 -m json.tool +``` + +**Expected response shape**: +```json +[{ + "integration": "shapeshiftSwap", + "quote": { + "swapper": "NEAR Intents", + "txs": [{ + "type": "transfer", + "txParams": { + "to": "bc1q...", ← BTC deposit address (from allowanceContract) + "recipientAddress": "bc1q...", + "memo": "", ← intentionally empty + "swapper": "NEAR Intents" + } + }] + } +}] +``` + +Key things to confirm: +- `txs[0].type` is `"transfer"` (not `"EVM"`) +- `txParams.to` is a valid `bc1q...` bech32 address +- `txParams.memo` is empty/absent +- `quote.swapper` is `"NEAR Intents"` + +### 2. Check swap health (verify THORChain is still halted) + +```bash +curl -s http://localhost:9001/api/v1/swap/health | python3 -m json.tool +# or against blue: +curl -s https://api-blue.keepkey.info/api/v1/swap/health | python3 -m json.tool +``` + +THORChain should show `"status": "degraded"` with BTC in `haltedPools`. +ShapeShift and Relay should show `"status": "ok"`. + +### 3. Apply vault changes + +The vault changes are on `portfolio-debug` in keepkey-vault-v11. Cherry-pick or apply manually: + +**`src/bun/swap-parsing.ts`** — around the `!memo && !hasPrebuiltTx` check (~line 185): + +```diff ++ // For memo-less UTXO swaps (e.g. NEAR Intents via ShapeShift), the deposit ++ // address IS the only instruction — no memo or calldata needed. ++ const isMemolessTransfer = !!inboundAddress && swapper === 'NEAR Intents' + if (!memo && !hasPrebuiltTx && !isNativeDeposit) { ++ if (!memo && !hasPrebuiltTx && !isNativeDeposit && !isMemolessTransfer) { + throw new Error('Quote returned no swap instructions ...') + } +``` + +**`src/bun/swap.ts`** — two places, same pattern: + +```diff + const hasPrebuiltTx = !!params.relayTx + const isNativeDeposit = isNativeDepositCaip(params.fromCaip) ++ const isMemolessTransfer = !!params.inboundAddress && params.swapper === 'NEAR Intents' + if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error(...) +- if (!params.memo && !hasPrebuiltTx) throw new Error('Missing swap memo from quote') ++ if (!params.memo && !hasPrebuiltTx && !isMemolessTransfer) throw new Error('Missing swap memo from quote') +``` + +(Occurs at ~line 400 in `executeSwap` and ~line 683 in `previewSwap`.) + +### 4. Run the e2e swap test + +```bash +cd e2e/swaps/e2e-swap-suite + +# Against blue Pioneer + local vault (typical local dev setup): +bun run simple-swap \ + "bip122:000000000019d6689c085ae165831e93/slip44:0" \ + "eip155:1/slip44:60" \ + "0.001" \ + --blue + +# Against local Pioneer: +bun run simple-swap \ + "bip122:000000000019d6689c085ae165831e93/slip44:0" \ + "eip155:1/slip44:60" \ + "0.001" \ + --local +``` + +**Expected flow**: +1. Quote fetched — `shapeshiftSwap / NEAR Intents` selected +2. Preview builds — plain BTC send to `bc1q...` deposit address, no memo +3. KeepKey shows "Send 0.001 BTC to bc1q..." (no OP_RETURN output) +4. User confirms on device +5. BTC tx broadcasts +6. ~13 minutes later ETH arrives at recipient address + +### 5. What "success" looks like in the UI + +When THORChain is halted, the swap dialog should: +- Show amber/red dot on THORChain in the provider health bar (if implemented) +- Fall through to ShapeShift NEAR Intents automatically +- Show no memo field in the preview (NEAR Intents is memo-less) +- Show estimated time ~13 minutes (812s from ShapeShift API) + +--- + +## Why the UTXO tx builder works without changes + +`createUnsignedUtxoTx` already handles empty memo correctly: + +```ts +const memoRaw = memo && memo.trim() ? memo.trim() : undefined +// ... +...(memoRaw ? { opReturnData: memoRaw } : {}), // skipped when empty +``` + +An empty memo produces a plain BTC send to `params.inboundAddress` (the deposit address) +with no OP_RETURN output — exactly what NEAR Intents requires. + +--- + +## Pairs that work via NEAR Intents + +NEAR Intents routes any UTXO→EVM pair ShapeShift supports. Confirmed working: +- BTC → ETH +- BTC → USDC (eip155:1) +- BTC → any EVM token ShapeShift lists + +UTXO→UTXO (BTC→LTC) and EVM→UTXO are not supported by NEAR Intents. + +--- + +## Current network status (as of 2026-05-15) + +| Integration | Status | Notes | +|---|---|---| +| THORChain | ❌ All pools halted | `trading_halted: true` on all 43 pools | +| MayaChain | ❌ Down | 502 from midgard | +| ShapeShift NEAR Intents | ✅ Working | BTC→ETH confirmed | +| Relay | ✅ Working | EVM↔EVM only | diff --git a/docs/handoff-pioneer-dedup-duplicates.md b/docs/handoff-pioneer-dedup-duplicates.md new file mode 100644 index 00000000..178fb062 --- /dev/null +++ b/docs/handoff-pioneer-dedup-duplicates.md @@ -0,0 +1,116 @@ +# Handoff: Pioneer `GetPortfolioBalances` Duplicate Token Entries + +**Date**: 2026-05-15 +**Severity**: P1 — wastes bandwidth, slows every portfolio load, forces vault to do client-side dedup +**Vault workaround**: `projects/keepkey-vault/src/bun/index.ts` dedupes both `tokensByChainId` and `evmTokensByOwner` after every Pioneer response — see commits on `develop` branch. + +--- + +## What the bug looks like + +Pioneer returns the same `(caip, pubkey)` pair multiple times in a single `GetPortfolioBalances` response: + +``` +DOG 0xafb89a09d82fbde58f18ac6437b3fc81724e4df6 48.3084 $0.02 ← dup 1 +DOG 0xafb89a09d82fbde58f18ac6437b3fc81724e4df6 48.3084 $0.02 ← dup 2 +PRO 0xef743df8eda497bcf1977393c401a636518dd630 100.380 $0.01 ← appears 6× +WETH 0x4200000000000000000000000000000000000006 0.000003 $0.01 ← dup 1 +WETH 0x4200000000000000000000000000000000000006 0.000003 $0.01 ← dup 2 +``` + +Identical amounts confirm these are not multiple addresses with the same balance — they are the **same entry emitted multiple times** by the same upstream source. + +--- + +## Where the bug lives + +**File**: `services/pioneer-server/src/controllers/balance.controller.ts` +**Method**: `GetPortfolioBalances` (lines 267–1289) + +The method calls **five upstream sources in sequence**, each with its own dedup guard: + +| Source | Dedup key | Problem | +|--------|-----------|---------| +| Balance cache | `caip::pubkey` | Built once at line ~395; later additions are invisible to it | +| Zapper | `caip::pubkey` | Built from `responses` snapshot before Zapper entries are added | +| Unchained | `caip` **only** | Drops legitimate holdings for address B if address A already has the same CAIP | +| SPL tokens | `caip` only | Same over-broad dedup | +| TRC-20 | `caip` only | Same | +| Extra contracts | `caip::pubkey` | Correct key, but again a snapshot | + +**Root cause**: Each source builds its exclusion set from a **snapshot** of `responses` at that moment. Entries added by the *same* source after the snapshot are not visible to the check. When Zapper (or any source) iterates over multiple addresses and emits the same token for the same address twice, the second emission is not caught. + +There is **no final dedup pass** before the response is returned. + +--- + +## The fix — one pass at the end + +Add a single dedup pass at the very end of `GetPortfolioBalances`, just before the return statement. This catches everything regardless of which upstream source caused the duplicate: + +```typescript +// ── Final dedup: normalize caip+pubkey keys and keep the first (best-data) entry ── +// Each upstream source has its own in-flight dedup, but snapshot timing means +// the same (caip, pubkey) pair can appear multiple times in `responses`. +const finalSeen = new Map() +for (const entry of responses) { + const key = `${(entry.caip || '').toLowerCase()}::${(entry.pubkey || '').toLowerCase()}` + if (!finalSeen.has(key)) { + finalSeen.set(key, entry) + } else { + // Keep the entry with the more recent fetchedAt (freshest data wins) + const existing = finalSeen.get(key)! + if ((entry.fetchedAt ?? 0) > (existing.fetchedAt ?? 0)) { + finalSeen.set(key, entry) + } + } +} +const deduped = [...finalSeen.values()] +if (deduped.length !== responses.length) { + console.warn(`[GetPortfolioBalances] dedup removed ${responses.length - deduped.length} duplicate entries`) +} +// Replace responses with deduped array before building final return value +responses = deduped // or however responses feeds the return shape +``` + +Adjust the variable name to match what `responses` is called locally at the end of the function (it may be `allBalances` or similar — check around line 1250). + +--- + +## Secondary fix — normalize Unchained dedup key + +The Unchained dedup (lines ~877, 931) uses `caip` only: + +```typescript +const existingCaips = new Set(responses.map((r: any) => r.caip)); +if (existingCaips.has(assetCaip)) continue; // BUG: drops address B if address A has same token +``` + +This causes false drops: if address A's USDT was already added from Zapper, address B's USDT (different pubkey, different balance) gets silently discarded. Change to `caip::pubkey`: + +```typescript +const existingKeys = new Set(responses.map((r: any) => `${(r.caip||'').toLowerCase()}::${(r.pubkey||'').toLowerCase()}`)) +const entryKey = `${assetCaip.toLowerCase()}::${pubkey.toLowerCase()}` +if (existingKeys.has(entryKey)) continue; +``` + +Apply the same change to SPL (line ~1032) and TRC-20 (line ~1128) dedup guards. + +--- + +## Acceptance criteria + +1. A single `GetPortfolioBalances` call with 5 EVM pubkeys returns **zero duplicate `(caip, pubkey)` pairs** +2. Two different pubkeys that legitimately both hold USDT each appear **once** with their own balance +3. Response payload is measurably smaller (each PRO duplicate above is an extra ~300 bytes × N addresses × polling frequency) + +--- + +## Related vault-side workaround (remove after Pioneer fix) + +Once Pioneer is clean, remove the dedup passes in the vault: + +- `getBalances` path: `tokensByChainId` dedup (~line 1922) and `evmTokensByOwner` dedup (~line 1945) +- `getBalance` path: `evmTokensByOwner` dedup (~line 2438) + +All in `projects/keepkey-vault/src/bun/index.ts` on the `develop` branch. diff --git a/docs/handoff-pioneer-swap-health-endpoint.md b/docs/handoff-pioneer-swap-health-endpoint.md new file mode 100644 index 00000000..1a3d525a --- /dev/null +++ b/docs/handoff-pioneer-swap-health-endpoint.md @@ -0,0 +1,132 @@ +# Handoff: Pioneer `GET /api/v1/swap/health` Endpoint + +**For:** Pioneer server (`services/pioneer-server`) +**Blocked by this:** Vault swap-page provider status lights +**Priority:** Medium — UX improvement, no existing endpoint + +--- + +## What we need + +A new endpoint `GET /api/v1/swap/health` that returns the operational status of each swap integration Pioneer supports. The vault uses this to show a row of colored status dots in the swap dialog header so users can see at a glance why a quote is failing (e.g. "THORChain degraded — TRON.TRX pool halted"). + +--- + +## Response shape + +```ts +interface SwapHealthResponse { + fetchedAt: number // Unix ms — client uses this to age the cache + integrations: IntegrationHealth[] +} + +interface IntegrationHealth { + key: string // 'thorchain' | 'mayachain' | 'shapeshift' | 'chainflip' + label: string // human-readable: "THORChain", "Mayachain", etc. + status: 'ok' | 'degraded' | 'offline' | 'unknown' + haltedPools?: string[] // CAIP-19s of pools that are staged/suspended (not empty) + detail?: string // one-line reason, e.g. "TRON.TRX pool suspended" +} +``` + +### Example response + +```json +{ + "fetchedAt": 1747350000000, + "integrations": [ + { + "key": "thorchain", + "label": "THORChain", + "status": "degraded", + "haltedPools": ["tron:0x2b6653dc/slip44:195"], + "detail": "1 pool suspended: TRON.TRX" + }, + { + "key": "mayachain", + "label": "Mayachain", + "status": "ok", + "haltedPools": [] + }, + { + "key": "shapeshift", + "label": "ShapeShift", + "status": "ok" + }, + { + "key": "chainflip", + "label": "Chainflip", + "status": "ok" + } + ] +} +``` + +--- + +## How to derive status + +### THORChain +The `swap-config.controller.ts` already calls `thorchain.getMarkets()` which returns an array of pools, each with `pool.status`. + +``` +pool.status === 'Available' → that pool is tradeable +pool.status === 'Staged' → depositable but not yet tradeable +pool.status === 'Suspended' → halted +``` + +Derivation logic: +- If `thorchain.getMarkets()` throws/returns non-200 → `status: 'offline'` +- If any pool is `Suspended` → `status: 'degraded'`, list the suspended asset strings in `haltedPools` +- Otherwise → `status: 'ok'` + +### Mayachain +Same pattern — `mayachain.getMarkets()` returns pools with `status`. + +### ShapeShift / Relay +ShapeShift is an aggregator routed through Pioneer's quote path. Use a lightweight probe: attempt to quote a known-liquid pair (e.g. ETH → BTC, 0.01 ETH) via the ShapeShift integration specifically, and record success/failure. Cache the result for 60s. + +Alternatively, if ShapeShift exposes a status page API, use that. + +For an initial simple version: if Pioneer itself is responding (we're handling this request), ShapeShift can be assumed `ok` unless a recent quote threw a non-recoverable error. Track quote failures per integration in a small in-memory map (TTL 5 min). + +### Chainflip +Same lightweight probe approach as ShapeShift. Chainflip has a public RPC: if `https://mainnet-archive.chainflip.io` responds, it's up. Cache for 60s. + +--- + +## Caching + +Do **not** call THORNode/Maya on every request — that's expensive. Use the same pool data that `getMarkets()` already fetches and caches. Suggested TTL: 30 seconds. + +The endpoint should return immediately from cache if the data is fresh, and trigger a background refresh if stale. + +``` +Cache key: 'swap:health' +TTL: 30s (or piggyback on getMarkets cache) +``` + +--- + +## Where to add it + +**Controller:** `src/controllers/swap-config.controller.ts` — new method `getSwapHealth()`, decorated `@Get('/health')` under `@Route('api/v1/swap')`. + +**Route registration:** Add to `src/routes.ts` following the same TSOA pattern as the other swap-config endpoints. + +--- + +## What the vault does with this + +The vault calls `getSwapHealth` once when the swap dialog opens and again every 60 seconds. It maps the response to three-color dots (green/amber/red) shown in the swap header next to each provider logo. When a quote fails with "pool halted / unavailable", the UI can cross-reference `haltedPools` to surface a specific message instead of a generic error. + +The vault already has `ProviderBadge.tsx` with icons for all four integrations (THORChain, Mayachain, ShapeShift, Chainflip). The new dots sit beside those. + +--- + +## Out of scope for Pioneer + +The vault side (RPC method + UI dots) is blocked on this endpoint existing. Once Pioneer ships it, the vault implementation is a few-hour task: +- New RPC `getSwapHealth` that fetches this endpoint +- `ProviderHealthBar` component (row of 4 dots + labels) +- Mount in SwapDialog header, poll every 60s diff --git a/docs/submodule-pinning-sop.md b/docs/submodule-pinning-sop.md index 99b576c1..c2f1f4e9 100644 --- a/docs/submodule-pinning-sop.md +++ b/docs/submodule-pinning-sop.md @@ -2,10 +2,14 @@ ## Overview -The vault depends on 5 git submodules under `modules/`. Each must be pinned to a -known-good commit on a well-defined branch before any release branch is cut. -Drift between submodule state and the pinned commit is the #1 source of -"works on my machine" build failures. +The Vault release build depends on 4 git submodules under `modules/`. Each must +be pinned to a known-good commit on a well-defined branch before any release +branch is cut. Drift between submodule state and the pinned commit is the #1 +source of "works on my machine" build failures. + +`modules/keepkey-firmware` is intentionally not a Vault release gate. It is used +for emulator and firmware development only; do not block desktop Vault releases +on its branch, nested submodules, or CI state. ## Submodule Inventory @@ -14,8 +18,13 @@ Drift between submodule state and the pinned commit is the #1 source of | **hdwallet** | `keepkey/hdwallet` | `master` | HD wallet core + KeepKey adapter (lodash/rxjs removed) | | **proto-tx-builder** | `BitHighlander/proto-tx-builder` | `main` | Cosmos/Thorchain/Maya TX builder (`@keepkey/proto-tx-builder`) | | **device-protocol** | `keepkey/device-protocol` | `master` | Protobuf message definitions — **must match firmware release** | -| **keepkey-firmware** | `keepkey/keepkey-firmware` | `master` or `release/X.Y.Z` | Emulator build, test fixtures — pin to release tag for stability | -| **electrobun** | `BitHighlander/electrobun` | `keepkey/macos-12-support` | Desktop framework fork — **tech debt: not yet merged to main** | +| **electrobun** | `blackboardsh/electrobun` | `main` | Desktop framework fork/runtime used by Vault | + +Ignored for Vault releases: + +| Module | Repo | Purpose | +|--------|------|---------| +| **keepkey-firmware** | `BitHighlander/keepkey-firmware` | Emulator build and firmware test fixtures only. Ignore for Vault packaging/release gating. | ## Pre-Release Pinning Checklist @@ -24,11 +33,13 @@ Run this BEFORE creating a `release/X.Y.Z` branch: ```bash cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11 -# 1. Fetch all remotes (top-level + nested) -git submodule foreach --recursive 'git fetch --all --prune 2>/dev/null || true' +# 1. Fetch Vault release-gated submodules only +for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun; do + git -C "$mod" fetch --all --prune 2>/dev/null || true +done -# 2. Check top-level submodules -for mod in modules/hdwallet modules/proto-tx-builder modules/keepkey-firmware modules/device-protocol modules/electrobun; do +# 2. Check release-gated submodules +for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun; do pinned=$(git ls-tree HEAD "$mod" | awk '{print substr($3,1,12)}') actual=$(cd "$mod" && git rev-parse --short=12 HEAD) branch=$(cd "$mod" && git branch --show-current 2>/dev/null || echo "detached") @@ -36,22 +47,10 @@ for mod in modules/hdwallet modules/proto-tx-builder modules/keepkey-firmware mo match="OK"; [ "$pinned" != "$actual" ] && match="DRIFT" echo "$mod: [$match] pinned=$pinned actual=$actual branch=$branch dirty=$dirty" done - -# 3. Check firmware nested submodules (deep tree — device-protocol, python-keepkey, trezor-firmware) -echo "" -echo "=== Firmware nested submodules ===" -drifted=$(cd modules/keepkey-firmware && git submodule status --recursive | grep '^+') -if [ -n "$drifted" ]; then - echo "⚠️ DRIFTED nested submodules:" - echo "$drifted" - echo "Fix: cd modules/keepkey-firmware && git submodule update --init --recursive" -else - echo "✅ All firmware nested submodules match pins" -fi ``` -**All modules must show `[OK]` and `dirty=0`, and firmware nested submodules -must have no `+` prefix, before cutting a release branch.** +**All release-gated modules must show `[OK]` and `dirty=0` before cutting a +release branch. Ignore `modules/keepkey-firmware` for Vault packaging.** ```bash # 4. Verify CI is green on every pinned commit @@ -61,8 +60,7 @@ declare -A REPOS=( ["modules/hdwallet"]="keepkey/hdwallet" ["modules/proto-tx-builder"]="BitHighlander/proto-tx-builder" ["modules/device-protocol"]="keepkey/device-protocol" - ["modules/keepkey-firmware"]="keepkey/keepkey-firmware" - ["modules/electrobun"]="BitHighlander/electrobun" + ["modules/electrobun"]="blackboardsh/electrobun" ) for mod in "${!REPOS[@]}"; do repo="${REPOS[$mod]}" @@ -90,10 +88,9 @@ done | Repo | Workflows | Notes | |------|-----------|-------| | keepkey/hdwallet | CI (build matrix) | Must pass | -| keepkey/keepkey-firmware | CI + Zoo Report | CI must pass; Zoo is informational | | BitHighlander/proto-tx-builder | Build & Test | Must pass | | keepkey/device-protocol | **None** | No CI — validate manually (lib/ build) | -| BitHighlander/electrobun | Build and Release + CEF Check | Build must pass; CEF is informational | +| blackboardsh/electrobun | Build and Release + CEF Check | Build must pass; CEF is informational | ## Per-Module Rules @@ -119,28 +116,17 @@ done - Verify: `cd modules/device-protocol && git log --oneline origin/master..HEAD` (should be empty) - If ahead of master: merge or rebase to master, push, then re-pin -### keepkey-firmware (`master` or `release/X.Y.Z`) - -- Pin to `master` HEAD for general development -- Pin to a `release/X.Y.Z` tag/branch when the vault targets a specific firmware -- **Has 7 nested submodules** (device-protocol, trezor-firmware, python-keepkey, - googletest, code-signing-keys, QR-Code-generator, SecAESSTM32) -- python-keepkey itself has 2 nested submodules (device-protocol, ethereum-lists) -- After any firmware pin change, run `cd modules/keepkey-firmware && git submodule update --init --recursive` -- Verify: `cd modules/keepkey-firmware && git submodule status --recursive | grep '^+'` (should be empty — `+` means drift) +### keepkey-firmware (ignored for Vault releases) -### electrobun (`keepkey/macos-12-support`) +- Not a desktop Vault release gate. +- Do not run recursive firmware submodule checks during Vault release prep. +- Do not block Vault packaging on firmware branch, nested submodules, or firmware CI. +- Only initialize and validate this repo when building the emulator or changing firmware fixtures. -**This is tech debt.** The fork has macOS 12/Intel support patches that have NOT -been merged to `main`. The branch diverged from `main` and both have moved forward -independently. +### electrobun (`main`) -- Pin to `keepkey/macos-12-support` branch HEAD -- This branch contains: macOS 12 Monterey + Intel Mac support, resign-swizzle removal -- `main` has: GPU screenshot readback, Bun 1.3.11 bump, WGPU cleanup -- **Action item**: Merge `main` into `keepkey/macos-12-support` (or vice versa) to - reduce divergence. Until then, this branch is required for Intel Mac builds. -- Verify: `cd modules/electrobun && git log --oneline origin/keepkey/macos-12-support -1` +- Pin to `blackboardsh/electrobun` `main` +- Verify: `cd modules/electrobun && git log --oneline origin/main -1` ## CRITICAL: Check for Upstream Fixes Before Release @@ -149,11 +135,12 @@ had moved to `92ea4dae` with critical transport fixes (Osmosis/Ethereum/Binance message type registration). The vault shipped with a broken Osmosis address derivation for months because the pin wasn't updated. -**Before every release, check ALL submodules for commits behind upstream:** +**Before every release, check every Vault release-gated submodule for commits +behind upstream:** ```bash echo "=== Commits behind upstream ===" -for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/keepkey-firmware modules/electrobun; do +for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun; do branch=$(cd "$mod" && git remote show origin 2>/dev/null | grep 'HEAD branch' | awk '{print $NF}') [ -z "$branch" ] && branch="master" (cd "$mod" && git fetch origin "$branch" 2>/dev/null) @@ -168,7 +155,7 @@ for mod in modules/hdwallet modules/proto-tx-builder modules/device-protocol mod done ``` -**If ANY submodule is behind, review the missing commits.** Bug fixes and +**If ANY release-gated submodule is behind, review the missing commits.** Bug fixes and transport/protocol changes MUST be pulled before release. Feature commits can be deferred if they introduce risk. @@ -193,7 +180,7 @@ git commit -m "chore: pin to HEAD ()" ## How to Verify Pinning After Checkout ```bash -git submodule update --init --recursive +git submodule update --init modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun git submodule status # No '+' prefix = pinned commit matches checkout # '+' prefix = DRIFT — submodule is on a different commit than pinned @@ -203,7 +190,7 @@ git submodule status The release skill (`~/.claude/skills/keepkey-vault-release.md`) Step 1 (Pre-flight Checks) must include the pinning checklist above. The release branch MUST NOT be -created until all submodules show `[OK]` and `dirty=0`. +created until all release-gated submodules show `[OK]` and `dirty=0`. ### Release Branch Gate @@ -212,10 +199,10 @@ Before `git checkout -b release/X.Y.Z develop`: 1. Run the pinning checklist (all OK, all clean) 2. **Run the upstream-behind check** — review and pull any bug fixes 3. Verify `device-protocol` is on upstream master (not alpha/feature branch) -4. Verify `keepkey-firmware` pin matches the firmware version being shipped -5. Verify `electrobun` is on `keepkey/macos-12-support` HEAD +4. Verify `electrobun` is on `main` HEAD 5. Verify `hdwallet` is on `master` with lodash/rxjs removal -6. Run `make build-stable` to confirm build succeeds with current pins +6. Ignore `modules/keepkey-firmware` unless this release explicitly changes emulator/firmware fixtures +7. Run `make build-stable` to confirm build succeeds with current pins ### Post-Release @@ -224,12 +211,17 @@ After release is published: - Feature-branch submodule updates go to `develop` only - If a hotfix requires a submodule change, document it in the release notes -## Current State (2026-04-03) +## Current State (2026-05-13) | Module | Pinned To | Branch | Status | |--------|-----------|--------|--------| -| hdwallet | `9b7b98af` | `master` | OK — latest master | -| proto-tx-builder | `368987a9` | `main` | OK — latest main | -| device-protocol | `cb2e0a96` | alpha (detached) | OK — master has all required protocol messages; re-pin to master HEAD before next release | -| keepkey-firmware | `98fb2103` | detached | OK — release/v7.14.0 merge commit | -| electrobun | `4f3d422a` | `keepkey/macos-12-support` | OK — latest on branch (tech debt: not merged to main) | +| hdwallet | `d83a65c3` | `master` | Current vault pin | +| proto-tx-builder | `f12f8c39` | `main` | Current vault pin | +| device-protocol | `bf8646b8` | `master` | Current vault pin; generated `lib/` still must be present on the build machine | +| electrobun | `73519358` | `main` | Current vault pin | + +Ignored for Vault release gating: + +| Module | Pinned To | Status | +|--------|-----------|--------| +| keepkey-firmware | `11d97d40` | Emulator/firmware fixture repo only; do not block Vault release on it | diff --git a/firmware/download-emulators.ts b/firmware/download-emulators.ts deleted file mode 100644 index 516ca488..00000000 --- a/firmware/download-emulators.ts +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env bun -/** - * Download KeepKey emulator binaries for each channel. - * - * Each channel's manifest entry declares a source { repo, ref, type }. - * This script resolves the exact commit SHA for the declared ref, then - * looks for CI artifacts or release assets built from THAT specific commit. - * If no matching artifact is found, it suggests a local build. - * - * Usage: - * bun firmware/download-emulators.ts # Download all channels - * bun firmware/download-emulators.ts --channel alpha # Download alpha only - * bun firmware/download-emulators.ts --channel beta # Download beta only - * bun firmware/download-emulators.ts --channel release # Download release only - * bun firmware/download-emulators.ts --fresh # Force re-download even if exists - * bun firmware/download-emulators.ts --status # Show what's installed - */ -import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'fs' -import { join, dirname } from 'path' -import { execSync } from 'child_process' - -const FIRMWARE_DIR = dirname(import.meta.path) -const EMULATORS_DIR = join(FIRMWARE_DIR, 'emulators') -const MANIFEST_PATH = join(EMULATORS_DIR, 'manifest.json') - -interface EmulatorSource { - repo: string - ref: string - type: 'branch' | 'commit' -} - -interface EmulatorEntry { - version: string - firmwareVersion: string - channel: string - arch: string - platform: string - dylib: string - binary: string - debugLink: boolean - description: string - source: EmulatorSource -} - -interface EmulatorManifest { - emulators: EmulatorEntry[] - default: string -} - -function loadManifest(): EmulatorManifest { - if (!existsSync(MANIFEST_PATH)) { - throw new Error(`Manifest not found: ${MANIFEST_PATH}`) - } - return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) -} - -/** Resolve the declared source ref to a full SHA. */ -function resolveSourceSha(source: EmulatorSource): string { - if (source.type === 'commit') { - // Already a SHA — verify it exists - try { - const full = execSync( - `gh api repos/${source.repo}/commits/${source.ref} --jq '.sha' 2>/dev/null`, - { encoding: 'utf-8' } - ).trim() - return full - } catch { - throw new Error(`Commit ${source.ref.slice(0, 12)} not found in ${source.repo}`) - } - } - - // Branch ref — resolve to HEAD SHA - try { - return execSync( - `gh api repos/${source.repo}/commits/${source.ref} --jq '.sha' 2>/dev/null`, - { encoding: 'utf-8' } - ).trim() - } catch { - throw new Error(`Branch ${source.ref} not found in ${source.repo}`) - } -} - -function getInstalledStatus(manifest: EmulatorManifest): void { - console.log('\n=== Emulator Channel Status ===\n') - for (const entry of manifest.emulators) { - const channelDir = join(EMULATORS_DIR, entry.version) - const dylibPath = join(EMULATORS_DIR, entry.dylib) - const binaryPath = join(EMULATORS_DIR, entry.binary) - const hasDylib = existsSync(dylibPath) - const hasBinary = existsSync(binaryPath) - const installed = hasDylib && hasBinary - const icon = installed ? '✅' : '❌' - - console.log(` ${icon} ${entry.channel.toUpperCase()} (${entry.version})`) - console.log(` ${entry.description}`) - console.log(` Source: ${entry.source.repo} @ ${entry.source.ref} (${entry.source.type})`) - if (installed) { - const stat = statSync(dylibPath) - const buildShaPath = join(channelDir, '.build-sha') - const buildSha = existsSync(buildShaPath) - ? readFileSync(buildShaPath, 'utf-8').trim().slice(0, 12) - : 'unknown' - console.log(` dylib: ${(stat.size / 1024 / 1024).toFixed(1)} MB, built from ${buildSha}, modified ${stat.mtime.toISOString().slice(0, 10)}`) - } else { - console.log(` NOT INSTALLED — run: make build-emulator-${entry.channel}`) - } - console.log() - } - console.log(` Default channel: ${manifest.default}`) -} - -/** - * Download emulator binaries for a channel. - * - * Strategy: - * 1. Resolve the declared source ref to a commit SHA - * 2. Check GitHub release assets tagged for that version - * 3. Check CI workflow artifacts, filtered by the resolved SHA - * 4. Fall back to local build suggestion - */ -async function downloadChannel(entry: EmulatorEntry, fresh: boolean): Promise { - const channelDir = join(EMULATORS_DIR, entry.version) - const dylibPath = join(EMULATORS_DIR, entry.dylib) - const binaryPath = join(EMULATORS_DIR, entry.binary) - - if (!fresh && existsSync(dylibPath) && existsSync(binaryPath)) { - console.log(` ⏭ ${entry.channel}: already installed (use --fresh to re-download)`) - return true - } - - mkdirSync(channelDir, { recursive: true }) - - const { repo } = entry.source - const platform = process.platform === 'darwin' ? 'macos' : 'linux' - const arch = process.arch === 'arm64' ? 'arm64' : 'x64' - const assetPattern = `emulator-${platform}-${arch}` - - try { - // Step 1: Resolve source to exact SHA - console.log(` 🔍 ${entry.channel}: resolving ${entry.source.type} ref ${entry.source.ref.slice(0, 12)}...`) - const targetSha = resolveSourceSha(entry.source) - console.log(` Target SHA: ${targetSha.slice(0, 12)}`) - - // Step 2: Check release assets (tagged releases) - const tag = `v${entry.firmwareVersion}` - try { - const releaseJson = execSync( - `gh api repos/${repo}/releases/tags/${tag} --jq '{tag_name, target_commitish, assets: [.assets[] | {name, id}]}' 2>/dev/null`, - { encoding: 'utf-8' } - ).trim() - - if (releaseJson) { - const release = JSON.parse(releaseJson) - const matchingAsset = release.assets.find((a: any) => a.name.includes(assetPattern)) - if (matchingAsset) { - // Verify the release was built from our target commit - const releaseCommit = execSync( - `gh api repos/${repo}/commits/${release.target_commitish} --jq '.sha' 2>/dev/null`, - { encoding: 'utf-8' } - ).trim() - - if (releaseCommit === targetSha) { - console.log(` 📦 ${entry.channel}: downloading from release ${tag} (SHA match: ${targetSha.slice(0, 12)})...`) - execSync( - `gh release download ${tag} --repo ${repo} --pattern "${assetPattern}*" --dir "${channelDir}" --clobber`, - { stdio: 'inherit' } - ) - const tarball = join(channelDir, `${assetPattern}.tar.gz`) - if (existsSync(tarball)) { - execSync(`tar xzf "${tarball}" -C "${channelDir}"`) - execSync(`rm -f "${tarball}"`) - } - if (existsSync(dylibPath) && existsSync(binaryPath)) { - execSync(`chmod +x "${binaryPath}"`) - writeFileSync(join(channelDir, '.build-sha'), targetSha + '\n') - console.log(` ✅ ${entry.channel}: installed from release (${targetSha.slice(0, 12)})`) - return true - } - } else { - console.log(` Release ${tag} SHA mismatch: release=${releaseCommit.slice(0, 12)}, want=${targetSha.slice(0, 12)} — skipping`) - } - } - } - } catch { - // No release found - } - - // Step 3: Check CI artifacts, filtered by the target SHA - console.log(` 🔍 ${entry.channel}: checking CI artifacts for SHA ${targetSha.slice(0, 12)}...`) - try { - // Find workflow artifacts that match our target SHA - const artifactsJson = execSync( - `gh api "repos/${repo}/actions/artifacts?name=${assetPattern}&per_page=20" --jq '[.artifacts[] | select(.expired == false) | {id, name, workflow_run: {head_sha: .workflow_run.head_sha, head_branch: .workflow_run.head_branch}}]' 2>/dev/null`, - { encoding: 'utf-8' } - ).trim() - - if (artifactsJson) { - const artifacts = JSON.parse(artifactsJson) - // Find an artifact whose workflow run was triggered by our target SHA - const matching = artifacts.find((a: any) => a.workflow_run.head_sha === targetSha) - - if (matching) { - console.log(` 📦 ${entry.channel}: downloading CI artifact (SHA: ${targetSha.slice(0, 12)}, branch: ${matching.workflow_run.head_branch})...`) - execSync( - `gh api "repos/${repo}/actions/artifacts/${matching.id}/zip" > "${channelDir}/artifact.zip" 2>/dev/null` - ) - execSync(`cd "${channelDir}" && unzip -o artifact.zip && rm artifact.zip`) - if (existsSync(dylibPath) && existsSync(binaryPath)) { - execSync(`chmod +x "${binaryPath}"`) - writeFileSync(join(channelDir, '.build-sha'), targetSha + '\n') - console.log(` ✅ ${entry.channel}: installed from CI artifact (${targetSha.slice(0, 12)})`) - return true - } - console.log(` Artifact extracted but expected binaries not found (${entry.dylib})`) - } else { - const shas = artifacts.map((a: any) => a.workflow_run.head_sha.slice(0, 12)) - console.log(` No CI artifact matches SHA ${targetSha.slice(0, 12)}`) - if (shas.length > 0) { - console.log(` Available artifact SHAs: ${shas.join(', ')}`) - } - } - } - } catch { - // No CI artifacts - } - - // Step 4: Fall back to local build - console.log(` ⚠️ ${entry.channel}: no pre-built binaries found for SHA ${targetSha.slice(0, 12)}`) - console.log(` Build locally with: make build-emulator-${entry.channel}`) - return false - - } catch (err: any) { - console.error(` ❌ ${entry.channel}: ${err.message}`) - return false - } -} - -// ── Main ──────────────────────────────────────────────────────────────── - -const args = process.argv.slice(2) -const channelFilter = args.includes('--channel') - ? args[args.indexOf('--channel') + 1] - : null -const fresh = args.includes('--fresh') -const statusOnly = args.includes('--status') - -const manifest = loadManifest() - -if (statusOnly) { - getInstalledStatus(manifest) - process.exit(0) -} - -console.log('\n=== KeepKey Emulator Download ===\n') - -const entries = channelFilter - ? manifest.emulators.filter(e => e.channel === channelFilter) - : manifest.emulators - -if (entries.length === 0) { - console.error(`No emulator entries found for channel: ${channelFilter}`) - console.error(`Available channels: ${manifest.emulators.map(e => e.channel).join(', ')}`) - process.exit(1) -} - -// Filter by current platform/arch -const platformEntries = entries.filter( - e => e.platform === process.platform && e.arch === process.arch -) - -if (platformEntries.length === 0) { - console.error(`No emulators available for ${process.platform}/${process.arch}`) - process.exit(1) -} - -let allOk = true -for (const entry of platformEntries) { - const ok = await downloadChannel(entry, fresh) - if (!ok) allOk = false -} - -console.log() -if (allOk) { - console.log('All requested emulators are ready.') -} else { - console.log('Some emulators need to be built locally. See instructions above.') - process.exit(1) -} diff --git a/firmware/emulators/7.10.0-alpha/kkemu b/firmware/emulators/7.10.0-alpha/kkemu deleted file mode 100755 index 15fa5478..00000000 Binary files a/firmware/emulators/7.10.0-alpha/kkemu and /dev/null differ diff --git a/firmware/emulators/7.10.0-alpha/libkkemu.dylib b/firmware/emulators/7.10.0-alpha/libkkemu.dylib deleted file mode 100755 index fa556681..00000000 Binary files a/firmware/emulators/7.10.0-alpha/libkkemu.dylib and /dev/null differ diff --git a/firmware/emulators/manifest.json b/firmware/emulators/manifest.json deleted file mode 100644 index c91d4977..00000000 --- a/firmware/emulators/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "emulators": [ - { - "version": "7.10.0-alpha", - "firmwareVersion": "7.10.0", - "channel": "alpha", - "arch": "arm64", - "platform": "darwin", - "dylib": "7.10.0-alpha/libkkemu.dylib", - "binary": "7.10.0-alpha/kkemu", - "debugLink": true, - "description": "Alpha — 7.10.0 emulator build (arm64 macOS)", - "source": { - "repo": "BitHighlander/keepkey-firmware", - "ref": "release/7.10.0", - "type": "branch" - } - } - ], - "default": "7.10.0-alpha" -} diff --git a/modules/device-protocol b/modules/device-protocol index bf8646b8..23523461 160000 --- a/modules/device-protocol +++ b/modules/device-protocol @@ -1 +1 @@ -Subproject commit bf8646b817401d4623e0977ddb4c961b1101121f +Subproject commit 235234614d3d0172366f89f34d682233d7590d8d diff --git a/modules/hdwallet b/modules/hdwallet index c0c4c73a..d83a65c3 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit c0c4c73ab5089f0e64860f51f310b5fe5953f3fd +Subproject commit d83a65c3cac22dc66d641897d6964effbed441dc diff --git a/modules/keepkey-firmware b/modules/keepkey-firmware index 9f52bb69..11d97d40 160000 --- a/modules/keepkey-firmware +++ b/modules/keepkey-firmware @@ -1 +1 @@ -Subproject commit 9f52bb69f2e32a71f08b31b0c7df788129a0578e +Subproject commit 11d97d4015b9c0fd96778d9954be3756831ef987 diff --git a/projects/keepkey-sdk/src/index.ts b/projects/keepkey-sdk/src/index.ts index 232bd1eb..36310784 100644 --- a/projects/keepkey-sdk/src/index.ts +++ b/projects/keepkey-sdk/src/index.ts @@ -14,8 +14,21 @@ import type { XrpSignTxParams, BnbSignTxParams, SolanaSignTxParams, + SolanaSignOffchainMessageParams, + SolanaOffchainMessageSignatureResult, TronSignTxParams, + TronSignMessageParams, + TronMessageSignatureResult, + TronVerifyMessageParams, + TronSignTypedHashParams, + TronTypedDataSignatureResult, TonSignTxParams, + TonSignMessageParams, + TonMessageSignatureResult, + TonBuildTransferParams, + TonBuildTransferResult, + TonFinalizeTransferParams, + TonFinalizeTransferResult, GetPublicKeyRequest, BatchPubkeysPath, ApplySettingsParams, @@ -450,6 +463,22 @@ export class KeepKeySdk { /** Sign a Solana transaction. `raw_tx` must be the base64-encoded serialized transaction. */ solanaSignTransaction: (params: SolanaSignTxParams): Promise => this.client.post('/solana/sign-transaction', params), + + /** + * Sign a Solana off-chain message with domain separation. Firmware + * builds the spec envelope (`\xff` || "solana offchain" || version || + * format || length || message) and Ed25519-signs it. NO AdvancedMode + * gate is needed — the envelope's leading `\xff` byte is invalid as a + * Solana transaction prefix, providing the domain separation that + * `solanaSignMessage` lacks. Format 2 (extended UTF-8) is rejected + * device-side; only formats 0 (ASCII) and 1 (UTF-8 limited, max 1212 + * bytes) are supported. Verifier MUST reconstruct the envelope locally + * and verify against it, NOT against the bare message. + */ + solanaSignOffchainMessage: ( + params: SolanaSignOffchainMessageParams, + ): Promise => + this.client.post('/solana/sign-offchain-message', params), } // ═══════════════════════════════════════════════════════════════════ @@ -461,6 +490,43 @@ export class KeepKeySdk { /** Sign a TRON transaction. `amount` is in sun (1 TRX = 1,000,000 sun). */ tronSignTransaction: (params: TronSignTxParams): Promise => this.client.post('/tron/sign-transaction', params), + + /** + * Sign a message under TIP-191 (TRON's analog of EIP-191 personal_sign): + * hash = keccak256("\x19TRON Signed Message:\n" + decimal(len) + msg) + * sig = secp256k1_sign(hash) → 65 bytes (r || s || 27+v) + * + * Pass `is_text=false` to send `message` as hex bytes; default treats + * it as UTF-8. + */ + tronSignMessage: ( + params: TronSignMessageParams, + ): Promise => + this.client.post('/tron/sign-message', params), + + /** + * Verify a TIP-191 signature against the claimed Base58Check address. + * The device recovers the secp256k1 pubkey, derives the canonical + * TRON address, and compares it against `address`. Returns + * `{ verified: boolean }`. + */ + tronVerifyMessage: ( + params: TronVerifyMessageParams, + ): Promise<{ verified: boolean }> => + this.client.post('/tron/verify-message', params), + + /** + * TIP-712 typed-data signing in hash mode. Host pre-computes the + * domainSeparator + message hashes per the TIP-712 spec; the device + * assembles + * keccak256("\x19\x01" || domain_separator_hash || message_hash) + * and signs with secp256k1. Both hashes must be exactly 32 bytes; + * omit `message_hash` for primaryType="EIP712Domain". + */ + tronSignTypedHash: ( + params: TronSignTypedHashParams, + ): Promise => + this.client.post('/tron/sign-typed-hash', params), } // ═══════════════════════════════════════════════════════════════════ @@ -472,6 +538,40 @@ export class KeepKeySdk { /** Sign a TON transaction. `raw_tx` must be the base64- or hex-encoded raw transaction. */ tonSignTransaction: (params: TonSignTxParams): Promise => this.client.post('/ton/sign-transaction', params), + + /** + * Bare Ed25519 over message bytes. NO domain separation — firmware + * fences this behind the `AdvancedMode` policy. With the policy + * disabled (default) this call returns a Failure response. Returns + * the 32-byte Ed25519 public key + 64-byte signature, both hex. + * + * For TON Connect-style auth flows, prefer the upcoming `ton_proof` + * envelope (separate endpoint, not yet implemented) which carries + * proper domain separation and doesn't need the policy gate. + */ + tonSignMessage: ( + params: TonSignMessageParams, + ): Promise => + this.client.post('/ton/sign-message', params), + + /** + * Build an unsigned TON v4R2 transfer. Fetches seqno and wallet + * state from TonCenter, constructs the body cell, and returns the + * 32-byte body hash the device should sign — the client never + * touches BOC/Cell internals. Echo the returned `build` object back + * to `tonFinalizeTransfer` after signing. + */ + tonBuildTransfer: (params: TonBuildTransferParams): Promise => + this.client.post('/ton/build-transfer', params), + + /** + * Finalize a signed TON transfer: assembles the external message + * BOC from the prior `build` + the device's Ed25519 signature, then + * broadcasts via TonCenter. Pass `broadcast: false` to skip the + * broadcast and inspect/retry manually. + */ + tonFinalizeTransfer: (params: TonFinalizeTransferParams): Promise => + this.client.post('/ton/finalize-transfer', params), } // ═══════════════════════════════════════════════════════════════════ diff --git a/projects/keepkey-sdk/src/types.ts b/projects/keepkey-sdk/src/types.ts index 57796a50..b332849d 100644 --- a/projects/keepkey-sdk/src/types.ts +++ b/projects/keepkey-sdk/src/types.ts @@ -158,6 +158,147 @@ export interface TonSignTxParams { raw_tx: string // base64 or hex encoded raw transaction } +// ── Message-signing surface (firmware 7.14.1+) ──────────────────── + +export interface TronSignMessageParams { + address_n?: number[] + addressNList?: number[] + /** UTF-8 string by default; pass is_text=false to send as hex bytes */ + message: string + is_text?: boolean + show_display?: boolean +} + +export interface TronMessageSignatureResult { + /** Base58Check signer address derived from the recovered pubkey */ + address: string + /** 65-byte recoverable secp256k1 signature (r || s || v), hex-encoded */ + signature: string +} + +export interface TronVerifyMessageParams { + address: string + /** Hex (with or without 0x) */ + signature: string + message: string + is_text?: boolean +} + +export interface TronSignTypedHashParams { + address_n?: number[] + addressNList?: number[] + /** 32-byte domainSeparator hash, hex (with or without 0x) */ + domain_separator_hash: string + /** 32-byte message hash, hex; omit for primaryType=EIP712Domain */ + message_hash?: string +} + +export interface TronTypedDataSignatureResult { + address: string + /** 65-byte recoverable secp256k1 signature, hex */ + signature: string +} + +export interface TonSignMessageParams { + address_n?: number[] + addressNList?: number[] + message: string + is_text?: boolean + show_display?: boolean +} + +export interface TonMessageSignatureResult { + /** 32-byte Ed25519 public key, hex */ + publicKey: string + /** 64-byte Ed25519 signature, hex */ + signature: string +} + +export interface SolanaSignOffchainMessageParams { + address_n?: number[] + addressNList?: number[] + message: string + is_text?: boolean + /** Spec version. Only 0 currently defined. */ + version?: number + /** 0 = restricted ASCII, 1 = UTF-8 limited (max 1212 bytes). 2 not supported. */ + message_format?: number + show_display?: boolean +} + +export interface SolanaOffchainMessageSignatureResult { + /** 32-byte Ed25519 public key, hex */ + publicKey: string + /** 64-byte Ed25519 signature over the spec envelope, hex */ + signature: string +} + +// ── TON build/finalize helpers ───────────────────────────────────── +// These wrap the vault's local v4R2 BOC builder so thin clients +// (browser extension, mobile) can issue a TON transfer without +// embedding a TON lib + toncenter plumbing. Build returns the +// unsigned body hash the device signs; finalize reassembles the +// signed BOC and (by default) broadcasts to TonCenter. + +export interface TonBuildTransferParams { + fromAddress: string + toAddress: string + /** Transfer amount in nanoTON, as a decimal string (BigInt-compatible). */ + amountNano: string + memo?: string + /** Ed25519 public key hex — only needed for first-time activation. */ + publicKeyHex?: string +} + +/** + * Opaque internal state carried between /ton/build-transfer and + * /ton/finalize-transfer. Callers should echo this back verbatim; + * they don't need to inspect it. + */ +export interface TonBuildResult { + bodyHash: string + rawTx: string + seqno: number + expireAt: number + toAddress: string + amountNano: string + needsDeploy: boolean + publicKeyHex?: string + _internal: { + destWorkchain: number + destHash: string + fromWorkchain: number + fromHash: string + amountNano: string + bounce: boolean + memo?: string + } +} + +export interface TonBuildTransferResult { + build: TonBuildResult + bodyHash: string + rawTx: string + seqno: number + expireAt: number + needsDeploy: boolean + feeEstimate: string +} + +export interface TonFinalizeTransferParams { + build: TonBuildResult + /** 64-byte Ed25519 signature, hex-encoded (128 chars). */ + signature: string + /** Default true. When false, vault returns the signed BOC without broadcasting. */ + broadcast?: boolean +} + +export interface TonFinalizeTransferResult { + boc: string + txid: string + broadcasted: boolean +} + // ── Public Key Types ──────────────────────────────────────────────── export interface GetPublicKeyRequest { address_n: number[] diff --git a/projects/keepkey-sdk/tests/evm-personal-sign/eth-sign-message.js b/projects/keepkey-sdk/tests/evm-personal-sign/eth-sign-message.js new file mode 100644 index 00000000..1bba4db5 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-personal-sign/eth-sign-message.js @@ -0,0 +1,115 @@ +/** + * evm-personal-sign/eth-sign-message.js — EIP-191 personal_sign + * + * End-to-end coverage of the `/eth/sign` endpoint plus the Vault's new + * EIP-191 decoding path (rest-api.ts preview branch + EthMessageSection in + * the approval dialog). + * + * Two cases are covered because firmware + vault render them differently: + * + * A) Single-line, all-ASCII-printable message + * - Firmware (`fsm_msgEthereumSignMessage` in lib/firmware/fsm_msg_ethereum.h): + * every byte passes `isprint()` → shows "Sign Message" + plaintext. + * - Vault: EthMessageSection shows the UTF-8 text prominently. + * + * B) Multi-line SIWE-style message (contains `\n` = 0x0A, not printable) + * - Firmware: `isprint()` fails on the first newline → flips to + * "Sign Bytes" mode and renders the whole message as hex. This + * is the *typical* case for real dApp login challenges (EIP-4361 + * mandates multi-line), which means the device physically cannot + * show the text — the user has no way to verify what they're + * signing without the Vault's decoded view. + * - Vault: EthMessageSection decodes the hex back to UTF-8 and shows + * the login challenge as readable text. This is the only place the + * user can actually read it. + * + * The test exercises both so a regression in either path fails loudly. + * + * Round-trip: every signed message is fed back to `/eth/verify` so we + * assert the device's own verify endpoint accepts the signature we just + * produced. That's the strongest proof that (address, message, signature) + * form a consistent triple — r/s/v formatting, the + * "\x19Ethereum Signed Message:\n{len}{msg}" prefix, and key derivation + * all match between sign and verify. + * + * Pioneer: not needed (no contract calldata involved). + * + * Runs: `npm run test:device` or + * `node tests/evm-personal-sign/eth-sign-message.js` + * + * Requires: vault running on 1646 with REST enabled, KeepKey connected + * and unlocked. If KEEPKEY_API_KEY is not set the SDK auto-pairs (expect + * a pair-approval prompt in the Vault before any signing). + */ +const { run, ETH_PATH } = require('../_helpers') + +// Case A: single-line, every byte is ASCII printable. Firmware will +// display "Sign Message" + the plaintext on-device. +const PLAINTEXT_SIMPLE = 'KeepKey SDK test: sign this string nonce DEADBEEF' + +// Case B: the shape of a real SIWE login challenge. Embedded `\n` bytes +// force the firmware into "Sign Bytes" (hex) mode — the vault is the +// only surface where the user can read this as text. +const PLAINTEXT_SIWE = + 'Sign in to KeepKey SDK Tests\n\n' + + 'This message proves you control the wallet.\n' + + 'Nonce: 0xDEADBEEF' + +function toHex(utf8) { + return '0x' + Buffer.from(utf8, 'utf8').toString('hex') +} + +async function signAndVerify({ sdk, address, label, plaintext, assert }) { + const messageHex = toHex(plaintext) + console.log(`\n -- Case: ${label} --`) + console.log(` Text: ${JSON.stringify(plaintext)}`) + console.log(` Hex: ${messageHex}`) + console.log('\n >>> APPROVE in Vault + on device <<<\n') + + const result = await sdk.eth.ethSignMessage({ + address, + addressNList: ETH_PATH, + message: messageHex, + }) + + const sig = result?.signature || result?.sig + assert(`[${label}] got sign result`, !!result) + assert(`[${label}] has signature field`, typeof sig === 'string' && sig.length > 0) + assert(`[${label}] signature is 0x-prefixed`, /^0x[0-9a-fA-F]+$/.test(sig)) + assert(`[${label}] signature is 65 bytes (130 hex chars + 0x)`, sig.length === 132) + console.log(` Signature: ${sig}`) + + // Cross-check: the device echoes which address signed. If it disagrees + // with our derived address the UI is showing the wrong signer in the + // approval dialog — catch that here instead of in production. + if (result.address) { + assert( + `[${label}] device-reported address matches derived`, + result.address.toLowerCase() === address.toLowerCase(), + ) + } + + console.log('\n >>> APPROVE "Verify message" on device <<<\n') + const ok = await sdk.eth.ethVerifyMessage({ + address, + message: messageHex, + signature: sig, + }) + assert(`[${label}] signature verifies against the signer`, ok === true) +} + +run('EIP-191 personal_sign (simple + SIWE shapes)', async (getSdk, assert) => { + const sdk = await getSdk() + + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + assert('Got ETH address', address && address.startsWith('0x')) + console.log(` Signer: ${address}`) + + // Case A — firmware should render "Sign Message" with the plaintext. + await signAndVerify({ sdk, address, label: 'simple', plaintext: PLAINTEXT_SIMPLE, assert }) + + // Case B — firmware will render "Sign Bytes" (hex) because of the + // embedded newlines. The Vault dialog must still show decoded UTF-8 so + // the user can read what they're approving. + await signAndVerify({ sdk, address, label: 'siwe', plaintext: PLAINTEXT_SIWE, assert }) +}) diff --git a/projects/keepkey-vault/README.md b/projects/keepkey-vault/README.md index 5e863fcb..97c151d5 100644 --- a/projects/keepkey-vault/README.md +++ b/projects/keepkey-vault/README.md @@ -2,6 +2,67 @@ A fast Electrobun desktop app template with React, Tailwind CSS, and Vite for hot module replacement (HMR). +## Linux support + +Vault ships three Linux x86_64 formats with each release: `.AppImage`, `.deb`, and a self-contained `.tar.zst`. ARM64 Linux is not built. + +### Tested distributions + +| Distribution | glibc | AppImage | .deb | tar.zst | +|----------------------|:-----:|:--------:|:----:|:-------:| +| Debian 12 (Bookworm) | 2.36 | ✅ | ✅ | ✅ | +| Debian 13 (Trixie) | 2.41 | ✅ | ✅ | ✅ | +| Ubuntu 22.04 LTS | 2.35 | ✅ | ✅ | ✅ | +| Ubuntu 24.04 LTS | 2.39 | ✅ | ✅ | ✅ | +| Linux Mint 21.x | 2.35 | ✅ | ✅ | ✅ | +| Pop!_OS 22.04 | 2.35 | ✅ | ✅ | ✅ | +| Fedora 38+ | 2.37 | ✅ | n/a | ✅ | +| RHEL/Rocky/Alma 9 | 2.34 | ⚠️ | n/a | ⚠️ | + +Minimum glibc target is **2.35**, set by the Electrobun WebKitGTK wrapper we build on Ubuntu 22.04. RHEL 9 (glibc 2.34) is one minor below the floor — it works for many users but isn't a release gate. + +### Installing + +**`.deb`** (Debian / Ubuntu / Mint / Pop!_OS): + +```bash +sudo apt install ./keepkey-vault__amd64.deb +``` + +Installs to `/opt/keepkey-vault/`, drops a launcher at `/usr/bin/keepkey-vault`, registers a `.desktop` entry, and installs `udev` rules so non-root users can talk to the device. + +**`.AppImage`**: + +```bash +chmod +x KeepKey-Vault-x86_64.AppImage +./KeepKey-Vault-x86_64.AppImage +``` + +You will likely need udev rules separately so the AppImage can see the KeepKey: + +```bash +sudo tee /etc/udev/rules.d/51-keepkey.rules >/dev/null <<'EOF' +SUBSYSTEM=="usb", ATTRS{idVendor}=="2b24", MODE="0666", GROUP="plugdev" +KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", MODE="0666", GROUP="plugdev" +EOF +sudo udevadm control --reload-rules && sudo udevadm trigger +``` + +**`.tar.zst`** (any distro, manual install): + +```bash +tar --use-compress-program=unzstd -xf stable-linux-x64-keepkey-vault.tar.zst +./keepkey-vault/bin/launcher +``` + +### Common runtime dependencies + +The `.deb` declares these as `Depends`. For `.AppImage` and `.tar.zst`, install them manually if missing: + +- `libgtk-3-0` +- `libwebkit2gtk-4.1-0` (or `libwebkit2gtk-4.0-37` on Ubuntu 22.04) +- `libayatana-appindicator3-1` (or `libappindicator3-1`) + ## Getting Started ```bash diff --git a/projects/keepkey-vault/__tests__/evm-max-send.test.ts b/projects/keepkey-vault/__tests__/evm-max-send.test.ts new file mode 100644 index 00000000..4101471a --- /dev/null +++ b/projects/keepkey-vault/__tests__/evm-max-send.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test' +import { buildEvmTx } from '../src/bun/txbuilder/evm' +import { CHAINS } from '../src/shared/chains' + +const ethereum = CHAINS.find(c => c.id === 'ethereum')! +const fromAddress = '0x000000000000000000000000000000000000bEEF' +const toAddress = '0x000000000000000000000000000000000000dEaD' +const tokenAddress = '0x000000000000000000000000000000000000c0fe' + +const pioneerWithBalance = (balance: string) => ({ + GetGasPriceByNetwork: async () => ({ data: '1' }), + GetNonceByNetwork: async () => ({ data: { nonce: 7 } }), + GetBalanceAddressByNetwork: async () => ({ data: { balance } }), +}) + +describe('EVM max send', () => { + test('rejects native max sends that cannot cover the rounded gas reserve', async () => { + await expect(buildEvmTx(pioneerWithBalance('0.000022'), ethereum, { + to: toAddress, + amount: '0', + isMax: true, + fromAddress, + })).rejects.toThrow('Insufficient funds to cover gas fees') + }) + + test('native max subtracts a rounded 10% gas reserve from balance', async () => { + const result = await buildEvmTx(pioneerWithBalance('0.1'), ethereum, { + to: toAddress, + amount: '0', + isMax: true, + fromAddress, + }) + + const gasFee = 21_000n * 1_000_000_000n + const gasReserve = (gasFee * 110n + 99n) / 100n + + expect(BigInt(result.value)).toBe(100_000_000_000_000_000n - gasReserve) + expect(result.gasLimit).toBe('0x5208') + expect(result.gasPrice).toBe('0x3b9aca00') + }) + + test('ERC-20 max reserves one display quantum before encoding transfer amount', async () => { + const result = await buildEvmTx(pioneerWithBalance('1'), ethereum, { + to: toAddress, + amount: '0', + isMax: true, + fromAddress, + caip: `eip155:1/erc20:${tokenAddress}`, + tokenBalance: '27.49591932', + tokenDecimals: 18, + }) + + expect(result.to).toBe(tokenAddress) + expect(BigInt(`0x${result.data.slice(-64)}`)).toBe(27_495_919_310_000_000_000n) + }) +}) diff --git a/projects/keepkey-vault/__tests__/evm-rpc-receipt.test.ts b/projects/keepkey-vault/__tests__/evm-rpc-receipt.test.ts new file mode 100644 index 00000000..6f9cd7f9 --- /dev/null +++ b/projects/keepkey-vault/__tests__/evm-rpc-receipt.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for getTxReceiptOnce — the single-shot EVM receipt poll used by + * swap-tracker's revert detection. Because the function intentionally swallows + * all errors as null (so transient RPC failures stay in the "still pending" + * state instead of mis-flagging swaps as failed), the boundary between + * "no receipt yet", "success", "revert", and "RPC threw" is exactly what we + * want to lock in tests. + * + * Run: bun test __tests__/evm-rpc-receipt.test.ts + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { getTxReceiptOnce } from '../src/bun/evm-rpc' + +const ORIG_FETCH = globalThis.fetch +const TX = '0xabc' +const URL = 'https://rpc.example/' + +function mockFetch(jsonResponse: any, opts?: { rejectWith?: Error }) { + globalThis.fetch = (async () => { + if (opts?.rejectWith) throw opts.rejectWith + return new Response(JSON.stringify(jsonResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as any +} + +describe('getTxReceiptOnce', () => { + beforeEach(() => { /* reset between tests */ }) + afterEach(() => { globalThis.fetch = ORIG_FETCH }) + + test('returns null when the receipt is not yet available (RPC returns null)', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, result: null }) + const r = await getTxReceiptOnce(URL, TX) + expect(r).toBeNull() + }) + + test('decodes a successful receipt (status 0x1)', async () => { + mockFetch({ + jsonrpc: '2.0', id: 1, + result: { status: '0x1', gasUsed: '0x5208', blockNumber: '0x1f4' }, + }) + const r = await getTxReceiptOnce(URL, TX) + expect(r).not.toBeNull() + expect(r!.status).toBe(true) + expect(r!.gasUsed).toBe(21000n) + expect(r!.blockNumber).toBe(500) + }) + + test('decodes a reverted receipt (status 0x0) — the case driving revert detection', async () => { + mockFetch({ + jsonrpc: '2.0', id: 1, + result: { status: '0x0', gasUsed: '0x5208', blockNumber: '0x2710' }, + }) + const r = await getTxReceiptOnce(URL, TX) + expect(r).not.toBeNull() + expect(r!.status).toBe(false) + expect(r!.blockNumber).toBe(10000) + }) + + test('returns null when the RPC throws (transient — caller will poll again)', async () => { + mockFetch(undefined, { rejectWith: new Error('connect ECONNRESET') }) + const r = await getTxReceiptOnce(URL, TX) + // Critical: a thrown fetch must NOT be propagated. The contract is + // "null until we have a definitive answer" so the swap stays pending + // through transient outages instead of being mis-marked failed. + expect(r).toBeNull() + }) + + test('returns null on JSON-RPC error (e.g. RPC method not supported)', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, error: { message: 'method not found', code: -32601 } }) + const r = await getTxReceiptOnce(URL, TX) + expect(r).toBeNull() + }) + + test('handles missing gasUsed/blockNumber gracefully (defensive)', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, result: { status: '0x1' } }) + const r = await getTxReceiptOnce(URL, TX) + expect(r).not.toBeNull() + expect(r!.status).toBe(true) + expect(r!.gasUsed).toBe(0n) + expect(r!.blockNumber).toBe(0) + }) +}) diff --git a/projects/keepkey-vault/__tests__/fixtures/swap/maya-completed-zec-to-usdc-a926.json b/projects/keepkey-vault/__tests__/fixtures/swap/maya-completed-zec-to-usdc-a926.json new file mode 100644 index 00000000..74ecc375 --- /dev/null +++ b/projects/keepkey-vault/__tests__/fixtures/swap/maya-completed-zec-to-usdc-a926.json @@ -0,0 +1,82 @@ +{ + "actions": [ + { + "date": "1778367610491156297", + "height": "16491079", + "in": [ + { + "address": "t1RDCnMNpvVMfngtFkEYq38LxD9c7cQHGch", + "coins": [ + { + "amount": "200000000", + "asset": "ZEC.ZEC" + } + ], + "txID": "A9260E10AE66DF46C4EE4128A664B41736DBD845D07041F67DE278F9CEF25A46" + } + ], + "metadata": { + "swap": { + "affiliateAddress": "", + "affiliateFee": "0", + "inPriceUSD": "619.6529830845467", + "isStreamingSwap": true, + "liquidityFee": "176447991601", + "memo": "=:ETH.USDC:0x5abB8C0677EA9D79562A3BD4a51c116669B355f0:0/1/0", + "networkFees": [ + { + "amount": "173754800", + "asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + } + ], + "outPriceUSD": "0.9375418351881475", + "streamingSwapMeta": { + "count": "11", + "depositedCoin": { + "amount": "200000000", + "asset": "ZEC.ZEC" + }, + "inCoin": { + "amount": "200000000", + "asset": "ZEC.ZEC" + }, + "interval": "1", + "lastHeight": "16491089", + "outCoin": { + "amount": "118648532200", + "asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + }, + "outEstimation": "118574159000", + "quantity": "11" + }, + "swapSlip": "40", + "swapTarget": "0" + } + }, + "out": [ + { + "address": "0x5abb8c0677ea9d79562a3bd4a51c116669b355f0", + "coins": [ + { + "amount": "118474777400", + "asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + } + ], + "height": "16491094", + "txID": "17AB8000BBD3CB951C83F917C5A93282C259F281CC284FBC5665BB226089609A" + } + ], + "pools": [ + "ZEC.ZEC", + "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + ], + "status": "success", + "type": "swap" + } + ], + "count": "1", + "meta": { + "nextPageToken": "164910799000000001", + "prevPageToken": "164910799000000001" + } +} diff --git a/projects/keepkey-vault/__tests__/fixtures/swap/maya-refund-eth-to-zec-7ce1.json b/projects/keepkey-vault/__tests__/fixtures/swap/maya-refund-eth-to-zec-7ce1.json new file mode 100644 index 00000000..b081cb8b --- /dev/null +++ b/projects/keepkey-vault/__tests__/fixtures/swap/maya-refund-eth-to-zec-7ce1.json @@ -0,0 +1,55 @@ +{ + "actions": [ + { + "date": "1778367413601667031", + "height": "16491045", + "in": [ + { + "address": "0x141d9959cae3853b035000490c03991eb70fc4ac", + "coins": [ + { + "amount": "4293210", + "asset": "ETH.ETH" + } + ], + "txID": "7CE15ACD233EA4DFEC386B45BBB347906E41E366D9C4DB95E735ED88F87BD42D" + } + ], + "metadata": { + "refund": { + "affiliateAddress": "", + "affiliateFee": "0", + "memo": "MidgardBadUTF8EncodedBase64: RLyTewAAAAAAAAAAAAAAAGoW+WHiTm6QvZ+VD3aNxCp/MFZkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmIaEefYoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGn/yYEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALT06WkVDLlpFQzp0MWd3d3lDZmJSTXlRZHdvOHhYck1HRGozWnFWamhzSFdUaAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "networkFees": [ + { + "amount": "75000", + "asset": "ETH.ETH" + } + ], + "reason": "MidgardBadUTF8EncodedBase64: aW52YWxpZCB0eCB0eXBlOiBEvJN7AAAAAAAAAAAAAAAAahb5YeJObpC9n5UPdo3EKn8wVmQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACYhoR59igAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaf/JgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtPQ==" + } + }, + "out": [ + { + "address": "0x141d9959cae3853b035000490c03991eb70fc4ac", + "coins": [ + { + "amount": "4218210", + "asset": "ETH.ETH" + } + ], + "height": "16491051", + "txID": "633F6EF365333E51CA5D315DAF787507663F6C8FC371C511C99D4B9266E5F6DD" + } + ], + "pools": [], + "status": "success", + "type": "refund" + } + ], + "count": "1", + "meta": { + "nextPageToken": "164910451000130011", + "prevPageToken": "164910451000130011" + } +} diff --git a/projects/keepkey-vault/__tests__/max-send.test.ts b/projects/keepkey-vault/__tests__/max-send.test.ts new file mode 100644 index 00000000..34ff858e --- /dev/null +++ b/projects/keepkey-vault/__tests__/max-send.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { + baseUnitsToDecimalString, + decimalToBaseUnits, + nativeMaxSpendableAmount, + normalizeDecimals, + tokenMaxPrecisionReserveUnits, + tokenMaxSpendableAmount, + tokenMaxSpendableBaseUnits, +} from '../src/shared/max-send' + +describe('token max send precision reserve', () => { + test('reserves one displayed 8-decimal quantum for 18-decimal tokens', () => { + expect(tokenMaxPrecisionReserveUnits(18)).toBe(10_000_000_000n) + expect(tokenMaxSpendableAmount('27.49591932', 18)).toBe('27.49591931') + expect(tokenMaxSpendableBaseUnits('27.49591932', 18)).toBe(27_495_919_310_000_000_000n) + }) + + test('reserves one base unit for low-decimal tokens', () => { + expect(tokenMaxPrecisionReserveUnits(6)).toBe(1n) + expect(tokenMaxSpendableAmount('27.495919', 6)).toBe('27.495918') + }) + + test('does not reserve a whole zero-decimal token', () => { + expect(tokenMaxPrecisionReserveUnits(0)).toBe(0n) + expect(tokenMaxSpendableAmount('1', 0)).toBe('1') + }) + + test('floors decimal parsing to token precision', () => { + expect(decimalToBaseUnits('1.123456789', 6)).toBe(1_123_456n) + }) + + test('accepts numeric-string token decimals from token metadata', () => { + expect(normalizeDecimals('18')).toBe(18) + expect(tokenMaxSpendableAmount('27.49591932', '18')).toBe('27.49591931') + }) + + test('does not run BigInt exponent math for invalid decimals', () => { + expect(normalizeDecimals(undefined)).toBeNull() + expect(decimalToBaseUnits('1.23', undefined)).toBeNull() + expect(baseUnitsToDecimalString(123n, undefined)).toBe('123') + expect(tokenMaxSpendableAmount('1.23', undefined)).toBe('1.23') + }) + + test('returns zero when the balance cannot cover the reserve', () => { + expect(tokenMaxSpendableAmount('0.000000001', 18)).toBe('0') + }) +}) + +describe('native max send fee reserve', () => { + test('subtracts reserve in base units without rounding SOL upward', () => { + expect(nativeMaxSpendableAmount('0.100000006', 9, '0.000005')).toBe('0.099995006') + }) + + test('returns zero when native balance cannot cover reserve', () => { + expect(nativeMaxSpendableAmount('0.000004999', 9, '0.000005')).toBe('0') + }) + + test('leaves native max unchanged when decimals are malformed', () => { + expect(nativeMaxSpendableAmount('1.23', undefined, '0.01')).toBe('1.23') + }) +}) diff --git a/projects/keepkey-vault/__tests__/relay-status.test.ts b/projects/keepkey-vault/__tests__/relay-status.test.ts new file mode 100644 index 00000000..c8cae494 --- /dev/null +++ b/projects/keepkey-vault/__tests__/relay-status.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test' +import { + mapRelayExecutionStatus, + relayOutboundTxid, + shouldApplyRelayStatus, +} from '../src/shared/relay-status' + +describe('Relay status mapping', () => { + test('maps terminal Relay statuses to vault swap statuses', () => { + expect(mapRelayExecutionStatus('success')).toBe('completed') + expect(mapRelayExecutionStatus('failure')).toBe('failed') + expect(mapRelayExecutionStatus('failed')).toBe('failed') + expect(mapRelayExecutionStatus('fallback')).toBe('refunded') + expect(mapRelayExecutionStatus('refund')).toBe('refunded') + }) + + test('maps in-flight Relay statuses without treating them as complete', () => { + expect(mapRelayExecutionStatus('waiting')).toBe('pending') + expect(mapRelayExecutionStatus('received')).toBe('confirming') + expect(mapRelayExecutionStatus('depositing')).toBe('confirming') + expect(mapRelayExecutionStatus('pending')).toBe('confirming') + expect(mapRelayExecutionStatus('delayed')).toBe('confirming') + expect(mapRelayExecutionStatus('submitted')).toBe('output_confirming') + expect(mapRelayExecutionStatus('unknown')).toBeNull() + }) + + test('does not let an in-flight Relay status downgrade a richer local state', () => { + expect(shouldApplyRelayStatus('output_detected', 'confirming')).toBe(false) + expect(shouldApplyRelayStatus('confirming', 'pending')).toBe(false) + expect(shouldApplyRelayStatus('pending', 'confirming')).toBe(true) + expect(shouldApplyRelayStatus('output_confirming', 'completed')).toBe(true) + expect(shouldApplyRelayStatus('completed', 'completed')).toBe(false) + expect(shouldApplyRelayStatus('completed', 'completed', true)).toBe(true) + }) + + test('uses inbound tx as outbound tx for same-chain successful Relay fills', () => { + expect(relayOutboundTxid({ + status: 'success', + originChainId: 1, + destinationChainId: 1, + inTxHashes: ['0xinput'], + txHashes: [], + }, '0xfallback')).toBe('0xinput') + }) + + test('prefers explicit Relay output hashes when present', () => { + expect(relayOutboundTxid({ + status: 'success', + originChainId: 1, + destinationChainId: 8453, + inTxHashes: ['0xinput'], + txHashes: ['0xoutput'], + }, '0xfallback')).toBe('0xoutput') + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-alt.test.ts b/projects/keepkey-vault/__tests__/solana-alt.test.ts new file mode 100644 index 00000000..c09a48de --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-alt.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for the Address Lookup Table resolver. + * + * All tests use an injected fetcher — no network I/O. Covers the ALT account + * parser (discriminator + 56-byte header + 32-byte address stride) plus the + * resolver's ownership check and graceful handling of missing / malformed + * accounts. + * + * Run: bun test __tests__/solana-alt.test.ts + */ +import { describe, test, expect } from 'bun:test' +import bs58 from 'bs58' +import { + parseAltAccountData, + resolveAlts, + SolanaAltResolveError, + ALT_HEADER_LEN, + ALT_PROGRAM_ID, + ALT_DISCRIMINATOR_LOOKUP_TABLE, + type AltAccountData, +} from '../src/bun/solana-alt' + +/** + * Build a well-formed ALT account header (56 bytes) with the + * `LookupTable` discriminator, no authority, followed by the given + * addresses. Mirrors Solana's bincode layout in + * `solana-sdk/address-lookup-table/program/src/state.rs`. + */ +function makeAltAccount(addresses: Uint8Array[]): Uint8Array { + const header = Buffer.alloc(ALT_HEADER_LEN) + // Discriminator = 1 (LookupTable) as u32 LE. + header.writeUInt32LE(ALT_DISCRIMINATOR_LOOKUP_TABLE, 0) + // authority_option = 0 (None) at byte 21. Rest stays zeroed. + header[21] = 0 + return Buffer.concat([header, ...addresses]) +} + +function altOwned(bytes: Uint8Array): AltAccountData { + return { data: bytes, owner: ALT_PROGRAM_ID } +} + +describe('parseAltAccountData', () => { + test('decodes a 2-address ALT to base58', () => { + const a1 = Buffer.alloc(32, 0x11) + const a2 = Buffer.alloc(32, 0x22) + const acct = makeAltAccount([a1, a2]) + const addrs = parseAltAccountData(acct) + expect(addrs).toHaveLength(2) + expect(addrs[0]).toBe(bs58.encode(a1)) + expect(addrs[1]).toBe(bs58.encode(a2)) + }) + + test('empty ALT (header only) yields no addresses', () => { + expect(parseAltAccountData(makeAltAccount([]))).toEqual([]) + }) + + test('rejects account shorter than header', () => { + expect(() => parseAltAccountData(Buffer.alloc(32))).toThrow(SolanaAltResolveError) + }) + + test('rejects body length not a multiple of 32', () => { + const acct = Buffer.concat([makeAltAccount([]), Buffer.alloc(10)]) + expect(() => parseAltAccountData(acct)).toThrow(SolanaAltResolveError) + }) + + test('rejects Uninitialized discriminator (0) — prevents non-ALT spoofing', () => { + // 56-byte header with discriminator=0, followed by two 32-byte pubkeys. + // Without discriminator validation this would decode cleanly — the + // attacker wins. With validation it throws. + const header = Buffer.alloc(ALT_HEADER_LEN) // all zeros → discriminator=0 + const payload = Buffer.concat([header, Buffer.alloc(32, 0x01), Buffer.alloc(32, 0x02)]) + expect(() => parseAltAccountData(payload)).toThrow(/discriminator/) + }) + + test('rejects garbage discriminator (2+) — prevents non-ALT spoofing', () => { + const header = Buffer.alloc(ALT_HEADER_LEN) + header.writeUInt32LE(7, 0) // bogus discriminator + const payload = Buffer.concat([header, Buffer.alloc(32, 0x42)]) + expect(() => parseAltAccountData(payload)).toThrow(/discriminator/) + }) + + test('rejects invalid authority_option byte (must be 0 or 1)', () => { + const header = Buffer.alloc(ALT_HEADER_LEN) + header.writeUInt32LE(ALT_DISCRIMINATOR_LOOKUP_TABLE, 0) + header[21] = 0xff // not a valid bincode Option tag + const payload = Buffer.concat([header, Buffer.alloc(32, 0x33)]) + expect(() => parseAltAccountData(payload)).toThrow(/authority_option/) + }) +}) + +describe('resolveAlts', () => { + test('maps each pubkey to its address list via injected fetcher', async () => { + const pk1 = bs58.encode(Buffer.alloc(32, 0xaa)) + const pk2 = bs58.encode(Buffer.alloc(32, 0xbb)) + const alt1Addrs = [Buffer.alloc(32, 0x01), Buffer.alloc(32, 0x02)] + const alt2Addrs = [Buffer.alloc(32, 0x03)] + const out = await resolveAlts([pk1, pk2], async (keys) => { + expect(keys).toEqual([pk1, pk2]) + return [altOwned(makeAltAccount(alt1Addrs)), altOwned(makeAltAccount(alt2Addrs))] + }) + expect(out.get(pk1)).toHaveLength(2) + expect(out.get(pk2)).toHaveLength(1) + expect(out.get(pk1)![0]).toBe(bs58.encode(alt1Addrs[0])) + }) + + test('skips missing (null) accounts silently', async () => { + const pk1 = bs58.encode(Buffer.alloc(32, 0xcc)) + const pk2 = bs58.encode(Buffer.alloc(32, 0xdd)) + const out = await resolveAlts([pk1, pk2], async () => [ + altOwned(makeAltAccount([Buffer.alloc(32, 1)])), + null, + ]) + expect(out.has(pk1)).toBe(true) + expect(out.has(pk2)).toBe(false) + }) + + test('skips malformed account data without throwing', async () => { + const pk = bs58.encode(Buffer.alloc(32, 0xee)) + const out = await resolveAlts([pk], async () => [altOwned(Buffer.alloc(20))]) // too small + expect(out.has(pk)).toBe(false) + }) + + test('skips accounts not owned by the ALT program (prevents spoofing)', async () => { + // Build an account whose *data* looks like a valid ALT but whose owner + // is a random program. A malicious v0 tx that references this account + // must not have its bytes surfaced as "resolved accounts". + const pk = bs58.encode(Buffer.alloc(32, 0x99)) + const bytes = makeAltAccount([Buffer.alloc(32, 0xfe)]) + const hostileOwner = bs58.encode(Buffer.alloc(32, 0x77)) + const out = await resolveAlts([pk], async () => [{ data: bytes, owner: hostileOwner }]) + expect(out.has(pk)).toBe(false) + }) + + test('empty input is a no-op', async () => { + const out = await resolveAlts([], async () => { throw new Error('should not fetch') }) + expect(out.size).toBe(0) + }) + + test('throws when fetcher returns a mismatched length', async () => { + const pk = bs58.encode(Buffer.alloc(32, 0xff)) + await expect(resolveAlts([pk], async () => [])).rejects.toThrow(SolanaAltResolveError) + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-instruction-decoder.test.ts b/projects/keepkey-vault/__tests__/solana-instruction-decoder.test.ts new file mode 100644 index 00000000..ffb0e153 --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-instruction-decoder.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for the Solana instruction decoder. + * + * These use the real pioneer-discovery registry (installed as a dep) so any + * mismatch between the schemas we ship and the decoder would surface here. + * Tests focus on: known programs decode correctly, unknown programs get a + * labelled fallback, malformed data is reported gracefully, and the ALT + * account expansion follows Solana's canonical resolution order. + * + * Run: bun test __tests__/solana-instruction-decoder.test.ts + */ +import { describe, test, expect } from 'bun:test' +import bs58 from 'bs58' +import { decodeInstruction, buildExpandedAccounts, PROGRAM_REGISTRY } from '../src/bun/solana-instruction-decoder' + +const SYSTEM_PROGRAM = '11111111111111111111111111111111' +const SPL_TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +const MEMO_V2 = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' +const UNKNOWN_PROGRAM = 'Fake11111111111111111111111111111111111111X' + +function u64Le(n: bigint): Buffer { + const b = Buffer.alloc(8) + b.writeBigUInt64LE(n, 0) + return b +} + +// ── Known program, known instruction ────────────────────────────────── + +describe('decodeInstruction — System.transfer', () => { + test('decodes discriminator + lamports arg, labels accounts', () => { + const data = Buffer.concat([Buffer.from([0x02, 0x00, 0x00, 0x00]), u64Le(1_500_000n)]) + const result = decodeInstruction({ + programIdIndex: 2, + accountIndices: [0, 1], + data, + expandedAccounts: ['AliceXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'BobXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', SYSTEM_PROGRAM], + }) + expect(result.status).toBe('known') + expect(result.programName).toBe('System Program') + expect(result.instructionName).toBe('transfer') + expect(result.args).toHaveLength(1) + expect(result.args[0]).toMatchObject({ name: 'lamports', type: 'u64', value: '1500000' }) + expect(result.accounts[0].label).toBe('source') + expect(result.accounts[1].label).toBe('destination') + }) +}) + +describe('decodeInstruction — SPL Token.transfer', () => { + test('1-byte discriminator + u64 amount', () => { + const data = Buffer.concat([Buffer.from([0x03]), u64Le(100_000_000n)]) + const result = decodeInstruction({ + programIdIndex: 3, + accountIndices: [0, 1, 2], + data, + expandedAccounts: ['SRCxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'DSTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'AUTHxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', SPL_TOKEN], + }) + expect(result.status).toBe('known') + expect(result.programName).toBe('SPL Token') + expect(result.instructionName).toBe('transfer') + expect(result.args[0]).toMatchObject({ name: 'amount', value: '100000000' }) + expect(result.accounts.map((a) => a.label)).toEqual(['source', 'destination', 'authority']) + }) + + test('transferChecked decodes amount + decimals', () => { + const data = Buffer.concat([Buffer.from([0x0c]), u64Le(1_000_000n), Buffer.from([6])]) + const result = decodeInstruction({ + programIdIndex: 0, + accountIndices: [1, 2, 3, 4], + data, + expandedAccounts: [SPL_TOKEN, 'src', 'mint', 'dst', 'auth'], + }) + expect(result.instructionName).toBe('transferChecked') + expect(result.args.map((a) => ({ name: a.name, value: a.value }))).toEqual([ + { name: 'amount', value: '1000000' }, + { name: 'decimals', value: '6' }, + ]) + }) +}) + +describe('decodeInstruction — Memo v2 (encoding=none)', () => { + test('labels program without decoding a discriminator', () => { + const data = Buffer.from('hello', 'utf-8') + const result = decodeInstruction({ + programIdIndex: 0, + accountIndices: [], + data, + expandedAccounts: [MEMO_V2], + }) + // Memo has no instructions defined — known program, unknown instruction. + expect(result.status).toBe('known-program-unknown-ix') + expect(result.programName).toBe('Memo v2') + expect(result.programCategory).toBe('utility') + }) +}) + +// ── Unknown / malformed ─────────────────────────────────────────────── + +describe('decodeInstruction — fallback paths', () => { + test('unknown program returns truncated id + no schema', () => { + const result = decodeInstruction({ + programIdIndex: 0, + accountIndices: [1], + data: Buffer.from([0xff]), + expandedAccounts: [UNKNOWN_PROGRAM, 'other'], + }) + expect(result.status).toBe('unknown-program') + expect(result.programName).toContain('…') + expect(result.args).toHaveLength(0) + expect(result.accounts[0].pubkey).toBe('other') + }) + + test('known program + unrecognized discriminator reports discriminator', () => { + const data = Buffer.from([0xfe]) // SPL Token has no 0xfe instruction + const result = decodeInstruction({ + programIdIndex: 0, + accountIndices: [], + data, + expandedAccounts: [SPL_TOKEN], + }) + expect(result.status).toBe('known-program-unknown-ix') + expect(result.discriminatorHex).toBe('fe') + expect(result.note).toContain('no schema for discriminator') + }) + + test('truncated args surface a note instead of throwing', () => { + // SPL Token transfer needs 1 disc + 8 bytes. Give 1 + 3. + const data = Buffer.concat([Buffer.from([0x03]), Buffer.from([0x01, 0x02, 0x03])]) + const result = decodeInstruction({ + programIdIndex: 0, + accountIndices: [], + data, + expandedAccounts: [SPL_TOKEN], + }) + expect(result.status).toBe('known') + expect(result.args).toHaveLength(0) + expect(result.note).toContain('truncated') + }) +}) + +// ── Expanded account ordering (static + ALT writable + ALT readonly) ─ + +describe('buildExpandedAccounts', () => { + test('preserves Solana resolution order: static → alt-writable → alt-readonly', () => { + const alt = bs58.encode(Buffer.alloc(32, 0xaa)) + const contents = ['w1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'w2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'r1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'r2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'] + const altContents = new Map([[alt, contents]]) + const { expanded, altOrigins } = buildExpandedAccounts( + ['SxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxI'], + [{ accountKey: alt, writableIndices: [0, 1], readonlyIndices: [2, 3] }], + altContents, + ) + expect(expanded).toEqual([ + 'SxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxI', + contents[0], contents[1], // writables first + contents[2], contents[3], // readonlies after + ]) + expect(altOrigins.map((o) => o.from)).toEqual([ + 'static', 'alt-writable', 'alt-writable', 'alt-readonly', 'alt-readonly', + ]) + }) + + test('missing ALT contents yields placeholder markers', () => { + const alt = bs58.encode(Buffer.alloc(32, 0x77)) + const { expanded } = buildExpandedAccounts( + [], + [{ accountKey: alt, writableIndices: [0], readonlyIndices: [] }], + new Map(), // ALT not resolved + ) + expect(expanded[0]).toContain(' { + test('System Program transfer is present with u64 lamports arg', () => { + const p = PROGRAM_REGISTRY.programs[SYSTEM_PROGRAM] + expect(p.instructions?.['02000000']?.args?.[0]).toEqual({ name: 'lamports', type: 'u64' }) + }) + test('SPL Token has 7 decoder-ready instructions', () => { + const ixs = PROGRAM_REGISTRY.programs[SPL_TOKEN].instructions! + expect(Object.keys(ixs).sort()).toEqual(['03', '04', '07', '08', '09', '0c', '0d']) + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-message-parser.test.ts b/projects/keepkey-vault/__tests__/solana-message-parser.test.ts new file mode 100644 index 00000000..ae1184fb --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-message-parser.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for parseSolanaMessage (structured v0 + legacy message parser). + * + * Focus: layout correctness. Every byte of the fixture is accounted for; + * `parseSolanaMessage` throws on any trailing bytes so a miscount in the + * fixture builder surfaces immediately. + * + * Run: bun test __tests__/solana-message-parser.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { parseSolanaMessage, SolanaTxParseError } from '../src/bun/solana-tx' + +// ── Byte-stream builder: mirrors Solana SDK's compact-u16 + account/ix layout + +function compactU16(n: number): Buffer { + if (n < 0x80) return Buffer.from([n]) + if (n < 0x4000) return Buffer.from([(n & 0x7f) | 0x80, (n >> 7) & 0x7f]) + return Buffer.from([(n & 0x7f) | 0x80, ((n >> 7) & 0x7f) | 0x80, (n >> 14) & 0x03]) +} + +function concat(...parts: Buffer[]): Buffer { + return Buffer.concat(parts) +} + +// ── Legacy: single-instruction SOL transfer shape ──────────────────── + +function buildLegacyTransferMsg(): Buffer { + // Header: 1 signer, 0 readonly signers, 1 readonly unsigned (System program) + const header = Buffer.from([1, 0, 1]) + // 2 static accounts: [signer, System program] + const signer = Buffer.alloc(32, 0x11) + const systemProgram = Buffer.alloc(32, 0x00) // all-zero pubkey == 11111...111 + const staticAccounts = concat(compactU16(2), signer, systemProgram) + // Recent blockhash + const blockhash = Buffer.alloc(32, 0xaa) + // One instruction: System.transfer(1_000_000 lamports), from account[0] to account[0] (dummy) + const programIdIndex = Buffer.from([1]) + const accountIndices = concat(compactU16(2), Buffer.from([0, 0])) + const data = Buffer.concat([ + Buffer.from([0x02, 0x00, 0x00, 0x00]), // discriminator u32-le = 2 (transfer) + Buffer.from([0x40, 0x42, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00]), // 1_000_000 LE + ]) + const ix = concat(programIdIndex, accountIndices, compactU16(data.length), data) + const instructions = concat(compactU16(1), ix) + return concat(header, staticAccounts, blockhash, instructions) +} + +// ── V0: same tx body + one ALT entry ──────────────────────────────── + +function buildV0TransferMsgWithAlt(): Buffer { + const prefix = Buffer.from([0x80]) + const body = buildLegacyTransferMsg() + // One ALT: pubkey + 1 writable index + 0 readonly + const altKey = Buffer.alloc(32, 0x55) + const altEntry = concat( + altKey, + compactU16(1), Buffer.from([7]), // writable indices [7] + compactU16(0), // no readonly + ) + const alts = concat(compactU16(1), altEntry) + return concat(prefix, body, alts) +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe('parseSolanaMessage — legacy', () => { + test('parses System.transfer: 1 signer, 2 static accounts, 1 instruction, no ALT', () => { + const msg = buildLegacyTransferMsg() + const parsed = parseSolanaMessage(msg) + expect(parsed.version).toBe('legacy') + expect(parsed.header).toEqual({ + numRequiredSignatures: 1, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + }) + expect(parsed.staticAccounts).toHaveLength(2) + expect(parsed.staticAccounts[0][0]).toBe(0x11) + expect(parsed.staticAccounts[1].every((b) => b === 0)).toBe(true) + expect(parsed.recentBlockhash[0]).toBe(0xaa) + expect(parsed.instructions).toHaveLength(1) + expect(parsed.instructions[0].programIdIndex).toBe(1) + expect(parsed.instructions[0].accountIndices).toEqual([0, 0]) + expect(parsed.instructions[0].data[0]).toBe(0x02) // discriminator = transfer + expect(parsed.altEntries).toHaveLength(0) + }) +}) + +describe('parseSolanaMessage — v0', () => { + test('detects 0x80 prefix and parses ALT section', () => { + const msg = buildV0TransferMsgWithAlt() + const parsed = parseSolanaMessage(msg) + expect(parsed.version).toBe('v0') + expect(parsed.instructions).toHaveLength(1) + expect(parsed.altEntries).toHaveLength(1) + expect(parsed.altEntries[0].accountKey.every((b) => b === 0x55)).toBe(true) + expect(parsed.altEntries[0].writableIndices).toEqual([7]) + expect(parsed.altEntries[0].readonlyIndices).toEqual([]) + }) + + test('unsupported version number rejected', () => { + const msg = Buffer.concat([Buffer.from([0x81]), buildLegacyTransferMsg()]) + expect(() => parseSolanaMessage(msg)).toThrow(SolanaTxParseError) + }) +}) + +describe('parseSolanaMessage — malformed', () => { + test('trailing byte rejected', () => { + const msg = Buffer.concat([buildLegacyTransferMsg(), Buffer.from([0xff])]) + expect(() => parseSolanaMessage(msg)).toThrow(/Trailing/) + }) + + test('zero static accounts rejected', () => { + const msg = Buffer.concat([ + Buffer.from([1, 0, 0]), + compactU16(0), // <-- zero accounts + Buffer.alloc(32), // blockhash + compactU16(0), // no instructions + ]) + expect(() => parseSolanaMessage(msg)).toThrow(SolanaTxParseError) + }) + + test('truncated instruction data rejected', () => { + const header = Buffer.from([1, 0, 0]) + const accts = concat(compactU16(1), Buffer.alloc(32)) + const hash = Buffer.alloc(32) + // Instruction claims 99-byte data but only 2 bytes follow + const badIx = concat(Buffer.from([0]), compactU16(0), compactU16(99), Buffer.from([0, 0])) + const ixs = concat(compactU16(1), badIx) + expect(() => parseSolanaMessage(concat(header, accts, hash, ixs))).toThrow(/truncated data/) + }) +}) + +// ── Large but valid: relaxed heuristic limits ──────────────────────── +// +// Earlier versions of the parser rejected any tx with more than 64 +// instructions or 32 ALT entries. Those caps were *heuristics* with no +// basis in the Solana protocol: aggregator routes (Jupiter, Phoenix, +// etc.) legitimately exceed them. The parser now only rejects counts +// that cannot physically fit in the remaining buffer — valid large +// txs are parsed successfully and invalid ones still throw. + +describe('parseSolanaMessage — relaxed count limits', () => { + /** Build a legacy message with `n` no-op instructions (3 bytes each). */ + function buildManyInstructionMsg(n: number): Buffer { + const header = Buffer.from([1, 0, 0]) + const accounts = concat(compactU16(1), Buffer.alloc(32)) + const blockhash = Buffer.alloc(32) + // Each ix is program_id_index(0) + 0 accts + 0 data = 3 bytes. + const oneIx = concat(Buffer.from([0]), compactU16(0), compactU16(0)) + const ixs = [compactU16(n)] + for (let i = 0; i < n; i++) ixs.push(oneIx) + return concat(header, accounts, blockhash, concat(...ixs)) + } + + test('accepts 128 instructions (well beyond the old 64-instruction heuristic cap)', () => { + const msg = buildManyInstructionMsg(128) + const parsed = parseSolanaMessage(msg) + expect(parsed.instructions).toHaveLength(128) + }) + + test('rejects an instruction count that cannot physically fit', () => { + // Declare 10_000 instructions in a ~15-byte instruction section. + const header = Buffer.from([1, 0, 0]) + const accts = concat(compactU16(1), Buffer.alloc(32)) + const hash = Buffer.alloc(32) + // compact-u16 for 10_000 = 3 bytes: 0x90, 0x4e, 0x00 — but use the helper + const ixs = concat(compactU16(10_000), Buffer.alloc(10)) + expect(() => parseSolanaMessage(concat(header, accts, hash, ixs))).toThrow(/cannot fit/) + }) + + test('accepts v0 messages with 50 ALT entries (beyond the old 32-entry heuristic cap)', () => { + const prefix = Buffer.from([0x80]) + const body = buildLegacyTransferMsg() + // One-per-key ALT with no writable/readonly indices: 32 + 1 + 1 = 34 bytes each. + const entry = concat(Buffer.alloc(32, 0x55), compactU16(0), compactU16(0)) + const entries: Buffer[] = [compactU16(50)] + for (let i = 0; i < 50; i++) entries.push(entry) + const msg = concat(prefix, body, concat(...entries)) + const parsed = parseSolanaMessage(msg) + expect(parsed.version).toBe('v0') + expect(parsed.altEntries).toHaveLength(50) + }) + + test('rejects an ALT count that cannot physically fit', () => { + const prefix = Buffer.from([0x80]) + const body = buildLegacyTransferMsg() + // Declare 10_000 ALT entries with almost no bytes following. + const alts = concat(compactU16(10_000), Buffer.alloc(10)) + expect(() => parseSolanaMessage(concat(prefix, body, alts))).toThrow(/cannot fit/) + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-transfer-fee.test.ts b/projects/keepkey-vault/__tests__/solana-transfer-fee.test.ts new file mode 100644 index 00000000..efbbebcf --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-transfer-fee.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { buildTx } from '../src/bun/txbuilder' +import { SOLANA_LAMPORTS_PER_SIGNATURE, solanaTransferLamportsForAmount } from '../src/bun/txbuilder/solana' +import { CHAINS } from '../src/shared/chains' + +const solana = CHAINS.find(c => c.id === 'solana')! +const solanaAddress = '11111111111111111111111111111111' + +describe('solanaTransferLamportsForAmount', () => { + test('converts native SOL amount to lamports without max adjustment', () => { + expect(solanaTransferLamportsForAmount('0.23438859')).toBe(234388590n) + }) + + test('reserves the signature fee for native SOL max swaps', () => { + expect(solanaTransferLamportsForAmount('0.23438859', true)).toBe(234388590n - SOLANA_LAMPORTS_PER_SIGNATURE) + }) + + test('rejects max swaps that cannot cover the Solana fee', () => { + expect(() => solanaTransferLamportsForAmount('0.000005', true)).toThrow(/network fee/i) + }) + + test('truncates to Solana native precision', () => { + expect(solanaTransferLamportsForAmount('1.1234567899')).toBe(1123456789n) + }) + + test('regular native SOL max send uses live balance instead of stale cached balance', async () => { + let transferParams: any + const pioneer = { + GetBalanceAddressByNetwork: async () => ({ data: { balance: '0.23438859' } }), + BuildSolanaTransfer: async (params: any) => { + transferParams = params + return { data: { serialized: 'solana-max-transfer' } } + }, + } + + const result = await buildTx(pioneer, solana, { + chainId: 'solana', + to: solanaAddress, + amount: '0', + isMax: true, + fromAddress: solanaAddress, + nativeBalance: '999', + }) + + expect(transferParams.amount).toBe('234383590') + expect(result.fee).toBe('0.000005') + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-tx.test.ts b/projects/keepkey-vault/__tests__/solana-tx.test.ts new file mode 100644 index 00000000..0828f727 --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-tx.test.ts @@ -0,0 +1,217 @@ +/** + * Unit tests for the Solana wire-transaction parser in src/bun/solana-tx.ts. + * + * These cover: + * 1. Legacy single- and multi-sig transactions (golden path). + * 2. Spec-correct v0 versioned transactions: [sigCount][sigs][0x80][msg_v0]. + * 3. The malformed `[0x80][sigCount][sigs][msg]` layout reported against + * the REST API — must throw a clear error, never silently pass garbage + * through to firmware. + * 4. Boundary conditions: empty buffer, truncated sigs, unreasonable counts. + * + * Run: bun test __tests__/solana-tx.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { parseSolanaTx, SolanaTxParseError, MAX_SIGNATURES, solanaMessageSlice } from '../src/bun/solana-tx' + +// ── Fixture builders ────────────────────────────────────────────────── + +/** Build a minimal legacy tx: [sigCount:1][64*sigCount sigs][legacyMsg]. */ +function legacyTx(sigCount: number): Buffer { + const sigs = Buffer.alloc(64 * sigCount) + // Legacy message: 3-byte header + 1 account key + 32-byte blockhash + 0 instructions + const msg = Buffer.concat([ + Buffer.from([1, 0, 0]), // header: 1 signer, 0 ro-signed, 0 ro-unsigned + Buffer.from([1]), // compact-u16 account count (1) + Buffer.alloc(32), // static account key (pubkey) + Buffer.alloc(32), // recent blockhash + Buffer.from([0]), // 0 instructions + ]) + return Buffer.concat([Buffer.from([sigCount]), sigs, msg]) +} + +/** Build a spec-correct v0 tx: [sigCount:1][64*sigCount sigs][0x80 msgV0]. */ +function versionedV0Tx(sigCount: number): Buffer { + const sigs = Buffer.alloc(64 * sigCount) + const msgV0 = Buffer.concat([ + Buffer.from([0x80]), // v0 prefix — spec puts this INSIDE the message + Buffer.from([1, 0, 0]), // header + Buffer.from([1]), // accounts compact-u16 + Buffer.alloc(32), // account + Buffer.alloc(32), // blockhash + Buffer.from([0]), // 0 instructions + Buffer.from([0]), // 0 address-lookup-table entries (v0-only) + ]) + return Buffer.concat([Buffer.from([sigCount]), sigs, msgV0]) +} + +/** Build the reported-bug layout: [0x80][sigCount][sigs][msg]. This is NOT + * how Solana serializes v0 — the parser must refuse it. */ +function malformedPrefixFirstTx(): Buffer { + return Buffer.concat([ + Buffer.from([0x80]), // incorrectly placed version prefix + Buffer.from([1]), // sigCount + Buffer.alloc(64), // sig + Buffer.from([1, 0, 0]), + Buffer.from([1]), + Buffer.alloc(32), + Buffer.alloc(32), + Buffer.from([0]), + Buffer.from([0]), + ]) +} + +// ── Golden path ─────────────────────────────────────────────────────── + +describe('parseSolanaTx — legacy', () => { + test('single-sig legacy: strips 1 sig and reports legacy message', () => { + const tx = legacyTx(1) + const parsed = parseSolanaTx(tx) + expect(parsed.sigStart).toBe(1) + expect(parsed.sigCount).toBe(1) + expect(parsed.messageStart).toBe(1 + 64) + expect(parsed.isVersioned).toBe(false) + expect(tx.subarray(parsed.messageStart)[0]).toBe(1) // first byte of header + }) + + test('multi-sig legacy (3 sigs): messageStart advances past all sigs', () => { + const tx = legacyTx(3) + const parsed = parseSolanaTx(tx) + expect(parsed.sigCount).toBe(3) + expect(parsed.messageStart).toBe(1 + 3 * 64) + expect(parsed.isVersioned).toBe(false) + }) +}) + +// ── Versioned v0 (spec format) ─────────────────────────────────────── + +describe('parseSolanaTx — v0 (spec-correct)', () => { + test('detects v0 prefix inside message, after sigs', () => { + const tx = versionedV0Tx(1) + const parsed = parseSolanaTx(tx) + expect(parsed.sigCount).toBe(1) + expect(parsed.messageStart).toBe(1 + 64) + expect(parsed.isVersioned).toBe(true) + // Byte at messageStart is the 0x80 prefix. + expect(tx[parsed.messageStart]).toBe(0x80) + }) +}) + +// ── Malformed inputs ────────────────────────────────────────────────── + +describe('parseSolanaTx — malformed input (must throw)', () => { + test('the reported bug: [0x80][sigCount][sigs][msg] is refused with a clear message', () => { + const tx = malformedPrefixFirstTx() + expect(() => parseSolanaTx(tx)).toThrow(SolanaTxParseError) + try { + parseSolanaTx(tx) + } catch (err) { + expect(err).toBeInstanceOf(SolanaTxParseError) + expect((err as Error).message).toMatch(/first byte/i) + expect((err as Error).message).toMatch(/versioned-message prefix/i) + } + }) + + test('empty buffer throws', () => { + expect(() => parseSolanaTx(Buffer.alloc(0))).toThrow(SolanaTxParseError) + }) + + test('zero sig count is refused (Solana transactions always have ≥1 signer)', () => { + // [0x00][anything] — compact-u16 = 0 signatures, which real Solana txs never have. + const tx = Buffer.concat([Buffer.from([0]), Buffer.alloc(10)]) + expect(() => parseSolanaTx(tx)).toThrow(SolanaTxParseError) + }) + + test('sig section larger than buffer is refused', () => { + // Claim 5 sigs but buffer is too small. + const tx = Buffer.concat([Buffer.from([5]), Buffer.alloc(64)]) // 65 bytes < 1+5*64 + expect(() => parseSolanaTx(tx)).toThrow(SolanaTxParseError) + }) + + test('reasonable upper-bound sigCount is accepted', () => { + // Build a tx with MAX_SIGNATURES sigs. + const sigs = Buffer.alloc(MAX_SIGNATURES * 64) + const msg = Buffer.concat([Buffer.from([1, 0, 0]), Buffer.from([1]), Buffer.alloc(32), Buffer.alloc(32), Buffer.from([0])]) + const tx = Buffer.concat([Buffer.from([MAX_SIGNATURES]), sigs, msg]) + const parsed = parseSolanaTx(tx) + expect(parsed.sigCount).toBe(MAX_SIGNATURES) + }) +}) + +// ── Regression: original stale-parser behavior would silently pass garbage ── + +describe('parseSolanaTx — regression vs original inline parser', () => { + test('the malformed layout is no longer silently forwarded with sigCount=128', () => { + // The pre-fix inline parser computed sigCount = (0x80 & 0x7f) | (fullTx[1] << 7) + // = 0 | (1 << 7) = 128 for malformedPrefixFirstTx(), then saw + // messageStart=8194 > fullTx.length, fell into the `if (messageStart < length)` + // guard being false, and shipped the full buffer to firmware. That silent + // no-op is why OpenSea's "Malformed Solana transaction" error appeared + // miles from the real parse failure. We now throw at the boundary. + const tx = malformedPrefixFirstTx() + expect(() => parseSolanaTx(tx)).toThrow(SolanaTxParseError) + }) +}) + +// ── solanaMessageSlice — bytes handed to the signer ─────────────────── + +describe('solanaMessageSlice', () => { + test('legacy: returns message starting with header (first byte is num_required_signatures)', () => { + const tx = legacyTx(1) + const parsed = parseSolanaTx(tx) + const slice = solanaMessageSlice(tx, parsed) + // First byte of legacy message is the header's num_required_signatures = 1. + expect(slice[0]).toBe(1) + // High bit must NOT be set (legacy has no version prefix). + expect(slice[0] & 0x80).toBe(0) + }) + + test('v0: returned slice starts with the 0x80 prefix', () => { + const tx = versionedV0Tx(1) + const parsed = parseSolanaTx(tx) + const slice = solanaMessageSlice(tx, parsed) + // The v0 prefix is THE thing that needs to be signed — Solana computes + // its signature over exactly these bytes, so dropping the prefix would + // produce a sig valid for the legacy-equivalent but NOT the v0 tx. + expect(slice[0]).toBe(0x80) + }) + + test('v0: slice length matches expected message size', () => { + const tx = versionedV0Tx(2) + const parsed = parseSolanaTx(tx) + const slice = solanaMessageSlice(tx, parsed) + // Full buffer = 1 (sigCount) + 2*64 (sigs) + N (message) + expect(slice.length).toBe(tx.length - parsed.messageStart) + }) +}) + +// ── Regression: signing-path reassembly contract ────────────────────── + +describe('signing reassembly — sigStart contract', () => { + test('parsed.sigStart points at the first signature byte', () => { + const tx = legacyTx(1) + const parsed = parseSolanaTx(tx) + // Overwrite bytes at sigStart..sigStart+64 with a mock signature, then + // verify the first byte after sigStart changed and the tail is intact. + const sig = Buffer.alloc(64, 0xab) + const out = Buffer.from(tx) + for (let i = 0; i < 64; i++) out[parsed.sigStart + i] = sig[i] + // First byte after the compact-u16 header is now 0xab. + expect(out[parsed.sigStart]).toBe(0xab) + expect(out[parsed.sigStart + 63]).toBe(0xab) + // Message portion (at parsed.messageStart) is unchanged. + expect(out[parsed.messageStart]).toBe(tx[parsed.messageStart]) + }) + + test('v0 tx: writing sig at sigStart preserves the 0x80 prefix in the message', () => { + const tx = versionedV0Tx(1) + const parsed = parseSolanaTx(tx) + const sig = Buffer.alloc(64, 0xcd) + const out = Buffer.from(tx) + for (let i = 0; i < 64; i++) out[parsed.sigStart + i] = sig[i] + // The v0 prefix lives at messageStart (= sigStart + sigCount*64), so + // even after signing the message's version byte must survive untouched. + expect(out[parsed.messageStart]).toBe(0x80) + expect(out[parsed.sigStart]).toBe(0xcd) + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-classify.test.ts b/projects/keepkey-vault/__tests__/swap-classify.test.ts new file mode 100644 index 00000000..e08ab37c --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-classify.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for classifySwapOutcome — the pure function that translates a Midgard + * /v2/actions response into a truthful status + outbound chain. + * + * Fixtures are real captured responses from mainnet midgard (Maya & THORChain). + * Replay tests pin behavior so future regressions to "refund displayed as + * completed" or "explorer URL keyed on toAsset" surface immediately. + * + * Run: bun test __tests__/swap-classify.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { classifySwapOutcome } from '../src/bun/swap/classify' +import refundEthToZec from './fixtures/swap/maya-refund-eth-to-zec-7ce1.json' +import completedZecToUsdc from './fixtures/swap/maya-completed-zec-to-usdc-a926.json' + +describe('classifySwapOutcome', () => { + // ── Failure 2: ETH→ZEC refund (the live in-flight swap our UI mis-rendered) + test('Maya refund (ETH→ZEC quote, refunded as ETH) is classified as refunded with source-chain outbound', () => { + const result = classifySwapOutcome(refundEthToZec as any) + + expect(result.status).toBe('refunded') + // The "outbound" of a refund is the source asset returning home. + expect(result.outboundAsset).toBe('ETH.ETH') + expect(result.outboundChainId).toBe('ethereum') // ← keys explorer URL + // The hash users see in our UI as "ZEC outbound" is actually an ETH refund tx. + expect(result.outboundTxid?.toUpperCase()).toBe('633F6EF365333E51CA5D315DAF787507663F6C8FC371C511C99D4B9266E5F6DD') + // 4218210 base units = 0.0421821 ETH (= 0.0429321 quoted minus 0.00075 fee) + expect(result.outboundAmount).toBe('4218210') + expect(result.inboundTxid?.toUpperCase()).toBe('7CE15ACD233EA4DFEC386B45BBB347906E41E366D9C4DB95E735ED88F87BD42D') + // Reason is captured even when Midgard mangles the encoding. + expect(result.refundReason).not.toBeNull() + }) + + // ── Known-good: ZEC→USDC native swap with a real on-chain outbound. Proves + // (a) status='completed', (b) outbound chain id is derived from the action's + // out asset (ETH for the USDC delivery) — NOT from a stale toAsset hint. + test('Maya completed swap routes outbound chain to the action.out asset', () => { + const result = classifySwapOutcome(completedZecToUsdc as any) + + expect(result.status).toBe('completed') + expect(result.outboundAsset).toBe('ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48') + expect(result.outboundChainId).toBe('ethereum') + expect(result.refundReason).toBeNull() + expect(result.outboundTxid).toBeTruthy() + expect(result.inboundTxid).toBeTruthy() + }) + + // ── Edge cases ──────────────────────────────────────────────── + test('empty response classifies as unknown', () => { + expect(classifySwapOutcome(null).status).toBe('unknown') + expect(classifySwapOutcome(undefined).status).toBe('unknown') + expect(classifySwapOutcome({ actions: [] }).status).toBe('unknown') + }) + + test('pending swap (no outbound leg) classifies as pending without an explorer link', () => { + const result = classifySwapOutcome({ + actions: [{ + type: 'swap', + status: 'pending', + in: [{ txID: 'AABBCC', coins: [{ amount: '100000000', asset: 'BTC.BTC' }] }], + out: [], + }], + }) + expect(result.status).toBe('pending') + expect(result.outboundTxid).toBeNull() + expect(result.outboundChainId).toBeNull() + }) + + test('swap action with success status but no outbound leg falls back to pending', () => { + // Defensive — Midgard occasionally lags between 'pending' and emitting + // the out leg. Better to render "pending" than a broken empty completion. + const result = classifySwapOutcome({ + actions: [{ + type: 'swap', + status: 'success', + in: [{ txID: 'AA', coins: [{ amount: '1', asset: 'BTC.BTC' }] }], + }], + }) + expect(result.status).toBe('pending') + }) + + test('outbound chain id is null when asset prefix is unknown — caller suppresses explorer link', () => { + const result = classifySwapOutcome({ + actions: [{ + type: 'swap', + status: 'success', + in: [{ txID: 'IN', coins: [{ amount: '1', asset: 'UNKNOWN.X' }] }], + out: [{ txID: 'OUT', coins: [{ amount: '1', asset: 'NEWCHAIN.NEWASSET' }] }], + }], + }) + expect(result.status).toBe('completed') + expect(result.outboundAsset).toBe('NEWCHAIN.NEWASSET') + expect(result.outboundChainId).toBeNull() + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-discovery.test.ts b/projects/keepkey-vault/__tests__/swap-discovery.test.ts new file mode 100644 index 00000000..1c26f6f7 --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-discovery.test.ts @@ -0,0 +1,434 @@ +/** + * Tests for the asset-picker discovery layer's pure logic: bucket selection, + * sort comparator, and ranked search. Synthetic AssetEntry inputs only — no + * pioneer-discovery JSON dependency, so the full bundle stays out of the + * test runner. + * + * The composite score formula `matchRank * 10 + bucket` is the user-facing + * contract; these cases pin the boundaries that drove that choice (notably + * "bitcoin" → BTC over BITCOIN memecoin, and "tron" → TRX even though it's + * unsupported). + * + * Run: bun test __tests__/swap-discovery.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { + bucketFor, + compareEntries, + buildSearchIndex, + searchEntries, + chainMetaForCaip2, + canonicalizeCaip, + canonicalizeChainCaip2, + parseCaip, + synthesizeSwapAsset, + type AssetEntry, +} from '../src/shared/swap-discovery' + +function entry(partial: Partial & Pick): AssetEntry { + return { + caip: partial.caip, + symbol: partial.symbol, + name: partial.name, + chainId: partial.chainId ?? partial.caip.split('/')[0], + decimals: partial.decimals ?? 18, + iconUrl: partial.iconUrl, + isNative: partial.isNative ?? !partial.caip.includes('/erc20:'), + balance: partial.balance, + swappable: partial.swappable, + swappableAsset: partial.swappableAsset, + availability: partial.availability ?? { status: 'unknown', providers: [] }, + } +} + +describe('bucketFor', () => { + const swap = { asset: 'BTC.BTC', chainId: 'bitcoin', symbol: 'BTC', name: 'Bitcoin', chainFamily: 'utxo', decimals: 8 } as any + + test('held with USD value → bucket 0', () => { + const e = entry({ + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', name: 'Bitcoin', + balance: { amount: '0.5', usd: 30000 }, + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + expect(bucketFor(e)).toBe(0) + }) + + test('held with $0 (no price feed) → bucket 1', () => { + const e = entry({ + caip: 'eip155:1/erc20:0xfoobar', symbol: 'WUT', name: 'WUT', + balance: { amount: '1', usd: 0 }, + availability: { status: 'unknown', providers: [] }, + }) + expect(bucketFor(e)).toBe(1) + }) + + test('Pioneer-confirmed swappable native (not held) → bucket 2', () => { + const e = entry({ + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', name: 'Bitcoin', + swappable: swap, + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + expect(bucketFor(e)).toBe(2) + }) + + test('Pioneer-confirmed swappable token (not held) → bucket 3', () => { + const e = entry({ + caip: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', name: 'Tether', + swappable: swap, + availability: { status: 'swappable', providers: ['relay'] }, + }) + expect(bucketFor(e)).toBe(3) + }) + + test('matrix-swappable native (no Pioneer entry) → bucket 4', () => { + const e = entry({ + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', name: 'Bitcoin', + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + expect(bucketFor(e)).toBe(4) + }) + + test('matrix-swappable token (no Pioneer entry) → bucket 5', () => { + const e = entry({ + caip: 'eip155:1/erc20:0xdeadbeef', symbol: 'X', name: 'Stablecoin', + isNative: false, + availability: { status: 'swappable', providers: ['relay'] }, + }) + expect(bucketFor(e)).toBe(5) + }) + + test('matrix-unknown (try a quote) → bucket 6', () => { + const e = entry({ + caip: 'eip155:1/erc20:0x1234', symbol: 'PEPE', name: 'Pepe', + isNative: false, + availability: { status: 'unknown', providers: [] }, + }) + expect(bucketFor(e)).toBe(6) + }) + + test('unsupported_chain → bucket 7', () => { + const e = entry({ + caip: 'tron:27Lqcw/slip44:195', symbol: 'TRX', name: 'TRON', + availability: { status: 'unsupported_chain', providers: [] }, + }) + expect(bucketFor(e)).toBe(7) + }) +}) + +describe('compareEntries — empty-query bucket sort', () => { + test('held + USD desc beats Pioneer-swappable not-held', () => { + const held = entry({ + caip: 'eip155:1/slip44:60', symbol: 'ETH', name: 'Ethereum', + balance: { amount: '1', usd: 3000 }, + availability: { status: 'swappable', providers: ['relay'] }, + }) + const swappable = entry({ + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', name: 'Bitcoin', + swappable: { asset: 'BTC.BTC' } as any, + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + expect(compareEntries(held, swappable)).toBeLessThan(0) + }) + + test('within bucket 0: highest USD first', () => { + const a = entry({ caip: 'a', symbol: 'A', name: 'A', balance: { amount: '1', usd: 100 } }) + const b = entry({ caip: 'b', symbol: 'B', name: 'B', balance: { amount: '1', usd: 5000 } }) + expect(compareEntries(a, b)).toBeGreaterThan(0) + }) + + test('non-bucket-0 ties break alphabetically by symbol', () => { + const a = entry({ caip: 'a', symbol: 'BBB', name: 'Beta', availability: { status: 'unknown', providers: [] } }) + const b = entry({ caip: 'b', symbol: 'AAA', name: 'Alpha', availability: { status: 'unknown', providers: [] } }) + expect(compareEntries(a, b)).toBeGreaterThan(0) + }) +}) + +describe('searchEntries — composite score', () => { + // The "bitcoin" UX bug we explicitly designed against: a memecoin with + // symbol "BITCOIN" must NOT outrank actual BTC when the user types "bitcoin". + const realBTC = entry({ + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', name: 'Bitcoin', + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + const memecoin = entry({ + caip: 'eip155:56/erc20:0xMEME', symbol: 'BITCOIN', name: 'BITCOIN Memecoin', + isNative: false, + availability: { status: 'unknown', providers: [] }, + }) + const trx = entry({ + caip: 'tron:27Lqcw/slip44:195', symbol: 'TRX', name: 'TRON', + availability: { status: 'unsupported_chain', providers: [] }, + }) + const tronToken = entry({ + caip: 'eip155:1/erc20:0xTRONISH', symbol: 'TRONISH', name: 'Tron Imitation', + isNative: false, + availability: { status: 'unknown', providers: [] }, + }) + + test('"bitcoin" → real BTC first (name match, swappable bucket)', () => { + const idx = buildSearchIndex([memecoin, realBTC]) + const r = searchEntries(idx, 'bitcoin') + expect(r[0].symbol).toBe('BTC') + expect(r[1].symbol).toBe('BITCOIN') + }) + + test('"BITCOIN" (uppercase) → real BTC first (case-insensitive)', () => { + const idx = buildSearchIndex([memecoin, realBTC]) + const r = searchEntries(idx, 'BITCOIN') + expect(r[0].symbol).toBe('BTC') + }) + + test('"tron" → TRX first even when unsupported (rank 0 trumps neighbor bucket)', () => { + // matchRank 0 + bucket 7 = 7. TRONISH is rank 1 (prefix) + bucket 6 = 16. + // TRX wins despite being in the worst bucket. + const idx = buildSearchIndex([tronToken, trx]) + const r = searchEntries(idx, 'tron') + expect(r[0].symbol).toBe('TRX') + expect(r[1].symbol).toBe('TRONISH') + }) + + test('"btc" exact-symbol → real BTC over substring matches', () => { + const wbtc = entry({ + caip: 'eip155:1/erc20:0xWBTC', symbol: 'WBTC', name: 'Wrapped BTC', + isNative: false, + availability: { status: 'unknown', providers: [] }, + }) + const idx = buildSearchIndex([wbtc, realBTC]) + const r = searchEntries(idx, 'btc') + expect(r[0].symbol).toBe('BTC') + }) + + test('empty query returns input unchanged', () => { + const idx = buildSearchIndex([realBTC, memecoin]) + expect(searchEntries(idx, '')).toEqual([realBTC, memecoin]) + expect(searchEntries(idx, ' ')).toEqual([realBTC, memecoin]) + }) + + test('CAIP substring match works', () => { + const idx = buildSearchIndex([realBTC, memecoin]) + const r = searchEntries(idx, 'bip122') + expect(r.length).toBe(1) + expect(r[0].symbol).toBe('BTC') + }) + + test('no match → empty array', () => { + const idx = buildSearchIndex([realBTC, memecoin]) + expect(searchEntries(idx, 'nonexistent_zzz')).toEqual([]) + }) +}) + +describe('chainMetaForCaip2', () => { + test('Bitcoin CAIP-2 resolves to chain meta with full native CAIP', () => { + const meta = chainMetaForCaip2('bip122:000000000019d6689c085ae165831e93') + expect(meta).not.toBeNull() + // The native CAIP-19 must include the slip44 segment — that's what + // AssetIcon's caipToIcon expects (CAIP-2 alone produces a wrong URL). + expect(meta!.nativeCaip).toContain('/slip44:') + expect(meta!.vaultChainId).toBe('bitcoin') + expect(meta!.chainFamily).toBe('utxo') + }) + + test('Ethereum CAIP-2 resolves correctly', () => { + const meta = chainMetaForCaip2('eip155:1') + expect(meta).not.toBeNull() + expect(meta!.vaultChainId).toBe('ethereum') + expect(meta!.nativeCaip).toBe('eip155:1/slip44:60') + expect(meta!.chainFamily).toBe('evm') + }) + + test('unknown CAIP-2 returns null (e.g. Monad-like)', () => { + expect(chainMetaForCaip2('eip155:99999')).toBeNull() + }) +}) + +describe('synthesizeSwapAsset', () => { + // Synthesis is what fires when the user picks a row Pioneer didn't include + // in GetAvailableAssets. Downstream quote/execute code is shaped around + // SwapAsset; this helper has to produce a valid one or refuse cleanly. + + test('native EVM asset → synthesized SwapAsset with full caip + no contract', () => { + const e = entry({ + caip: 'eip155:1/slip44:60', symbol: 'ETH', name: 'Ethereum', + chainId: 'eip155:1', + availability: { status: 'swappable', providers: ['relay'] }, + }) + const s = synthesizeSwapAsset(e) + expect(s).not.toBeNull() + expect(s!.caip).toBe('eip155:1/slip44:60') + expect(s!.contractAddress).toBeUndefined() + expect(s!.chainId).toBe('ethereum') // vault internal id + expect(s!.chainFamily).toBe('evm') + expect(s!.symbol).toBe('ETH') + expect(s!.asset).toMatch(/^[A-Z]+\.ETH$/) + }) + + test('ERC-20 token → contract address parsed out of the caip', () => { + const e = entry({ + caip: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', name: 'Tether', + chainId: 'eip155:1', + isNative: false, + availability: { status: 'swappable', providers: ['relay', 'zeroex'] }, + }) + const s = synthesizeSwapAsset(e) + expect(s).not.toBeNull() + expect(s!.contractAddress).toBe('0xdac17f958d2ee523a2206206994597c13d831ec7') + expect(s!.caip).toBe(e.caip) + expect(s!.asset).toContain('USDT') + expect(s!.asset.toUpperCase()).toContain('0XDAC17F958D2EE523A2206206994597C13D831EC7') + }) + + test('Bitcoin native → utxo family + synthetic THORChain-style asset', () => { + const e = entry({ + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', name: 'Bitcoin', + chainId: 'bip122:000000000019d6689c085ae165831e93', + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + const s = synthesizeSwapAsset(e) + expect(s).not.toBeNull() + expect(s!.chainFamily).toBe('utxo') + expect(s!.chainId).toBe('bitcoin') + }) + + test('unknown chain → returns null (caller should refuse the click)', () => { + const e = entry({ + caip: 'eip155:99999/erc20:0xfoo', symbol: 'X', name: 'Y', + chainId: 'eip155:99999', + availability: { status: 'unknown', providers: [] }, + }) + expect(synthesizeSwapAsset(e)).toBeNull() + }) + + test('TRON token (alternate base58 encoding) resolves to vault chain meta', () => { + // pioneer-discovery emits tron:27Lqcw (base58 of the genesis hash) while + // vault's CHAINS table stores tron:0x2b6653dc (hex). The alias table in + // getChainMetaMap aliases both encodings to the same ChainMeta so synthesis + // succeeds — earlier this returned null and the user saw TRON USDT as + // unselectable in the picker. + const e = entry({ + caip: 'tron:27Lqcw/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + symbol: 'USDT', name: 'Tether', + chainId: 'tron:27Lqcw', + isNative: false, + availability: { status: 'swappable', providers: ['thorchain'] }, + }) + const s = synthesizeSwapAsset(e) + expect(s).not.toBeNull() + expect(s!.chainId).toBe('tron') + expect(s!.contractAddress).toBe('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + }) +}) + +describe('canonicalizeChainCaip2 / canonicalizeCaip', () => { + // The TRON 3-row regression: pioneer-discovery emits tron:27Lqcw, + // tron:27lqcw, AND tron:0x2b6653dc — without canonicalization the picker + // shows three TRX entries, two of them disabled. Pin the alias resolution + // so a future matrix edit can't silently fragment the picker again. + test('TRON base58 (mixed-case) → hex canonical', () => { + expect(canonicalizeChainCaip2('tron:27Lqcw')).toBe('tron:0x2b6653dc') + expect(canonicalizeChainCaip2('tron:27lqcw')).toBe('tron:0x2b6653dc') + }) + + test('Hyperliquid is intentionally NOT aliased', () => { + // The 2868 vs 999 mismatch between vault CHAINS and chainID.network + // is unresolved. Aliasing 2868→999 would let the picker show Hyperliquid + // as swappable, but vault's ChainDef sits at 2868 (with a non-mainnet + // chainId) so click would silently fail in the synthesizer. Until the + // upstream is reconciled, both encodings pass through and Hyperliquid + // shows in the picker as unsupported_chain with a clear reason. + expect(canonicalizeChainCaip2('eip155:2868')).toBe('eip155:2868') + expect(canonicalizeChainCaip2('eip155:999')).toBe('eip155:999') + }) + + test('canonical encodings pass through unchanged', () => { + expect(canonicalizeChainCaip2('eip155:1')).toBe('eip155:1') + expect(canonicalizeChainCaip2('tron:0x2b6653dc')).toBe('tron:0x2b6653dc') + }) + + test('canonicalizeCaip rewrites only the chain prefix, preserves the rest', () => { + expect(canonicalizeCaip('tron:27Lqcw/slip44:195')) + .toBe('tron:0x2b6653dc/slip44:195') + expect(canonicalizeCaip('tron:27lqcw/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t')) + .toBe('tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + expect(canonicalizeCaip('eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7')) + .toBe('eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7') // already canonical + }) + + test('chain-only CAIP-2 (no slash) still aliases', () => { + expect(canonicalizeCaip('tron:27Lqcw')).toBe('tron:0x2b6653dc') + }) + + test('BSC token namespace folds /bep20: → /erc20: at canonicalization', () => { + // Outgoing CAIP that goes to Pioneer Quote must be /erc20: — pioneer-server + // returns "No quotes available" for /bep20: BSC USDT. Folding here means + // the picker, matrix lookup, and quote call all see the same form. + expect(canonicalizeCaip('eip155:56/bep20:0x55d398326f99059ff775485246999027b3197955')) + .toBe('eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955') + // /erc20: pass-through (already canonical). + expect(canonicalizeCaip('eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955')) + .toBe('eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955') + // Other chains' bep20-look-alike namespaces are NOT touched (unlikely but + // defensive — bep20 only exists on BSC). + expect(canonicalizeCaip('eip155:1/bep20:0xfoo')).toBe('eip155:1/bep20:0xfoo') + }) +}) + +describe('parseCaip — single source for namespace classification', () => { + // Earlier homemade isNative checks excluded `/erc20:` and `/token:` but + // missed `/bep20:` — pioneer-discovery emits BSC tokens under that namespace. + // The picker classified BSC tokens as native, the synthesizer dropped their + // contractAddress, and the dialog showed native BNB pricing for them. + test('ERC-20 → token + contract address preserved', () => { + const r = parseCaip('eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7') + expect(r.isToken).toBe(true) + expect(r.contractAddress).toBe('0xdac17f958d2ee523a2206206994597c13d831ec7') + expect(r.chainCaip2).toBe('eip155:1') + }) + + test('BEP-20 → token + contract address preserved (was the bug)', () => { + const r = parseCaip('eip155:56/bep20:0x55d398326f99059ff775485246999027b3197955') + expect(r.isToken).toBe(true) + expect(r.contractAddress).toBe('0x55d398326f99059ff775485246999027b3197955') + }) + + test('TRON token namespace (case-sensitive contract) → preserved as-is', () => { + const r = parseCaip('tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + expect(r.isToken).toBe(true) + // Contract case must be preserved — base58 is case-sensitive on TRON. + expect(r.contractAddress).toBe('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') + }) + + test('Native chain (slip44) → not a token', () => { + expect(parseCaip('eip155:1/slip44:60').isToken).toBe(false) + expect(parseCaip('bip122:000000000019d6689c085ae165831e93/slip44:0').isToken).toBe(false) + }) + + test('Chain-only (no namespace) → not a token', () => { + expect(parseCaip('eip155:1').isToken).toBe(false) + }) +}) + +describe('synthesizeSwapAsset — BEP-20 contract is preserved (regression)', () => { + test('BSC USDT (bep20 namespace) keeps contract address', () => { + const e = entry({ + caip: 'eip155:56/bep20:0x55d398326f99059ff775485246999027b3197955', + symbol: 'USDT', name: 'Tether', + chainId: 'eip155:56', + isNative: false, + availability: { status: 'swappable', providers: ['relay'] }, + }) + const s = synthesizeSwapAsset(e) + expect(s).not.toBeNull() + expect(s!.contractAddress).toBe('0x55d398326f99059ff775485246999027b3197955') + // Synthesized asset string also gets the contract via THORChain convention. + expect(s!.asset).toContain('-0X55D398326F99059FF775485246999027B3197955') + expect(s!.chainId).toBe('bsc') + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-parsing.test.ts b/projects/keepkey-vault/__tests__/swap-parsing.test.ts index eaa2f894..9de6c032 100644 --- a/projects/keepkey-vault/__tests__/swap-parsing.test.ts +++ b/projects/keepkey-vault/__tests__/swap-parsing.test.ts @@ -119,17 +119,25 @@ const FIXTURE_SINGLE_QUOTE = { }, } -/** Assets response from Pioneer GetAvailableAssets */ +/** Assets response from Pioneer GetAvailableAssets — every entry includes + * caip per pioneer-server's swap-config controller contract (the response is + * built from a CAIP-keyed whitelist). The token-without-caip fixture below + * pins the malformed-response defense. */ 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: 'BTC.BTC', symbol: 'BTC', name: 'Bitcoin', decimals: 8, + caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0' }, + { asset: 'ETH.ETH', symbol: 'ETH', name: 'Ethereum', decimals: 18, + caip: 'eip155:1/slip44:60' }, + { asset: 'ETH.USDT-0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', name: 'Tether USD', decimals: 6, + caip: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' }, + { asset: 'GAIA.ATOM', symbol: 'ATOM', name: 'Cosmos Hub', decimals: 6, + caip: 'cosmos:cosmoshub-4/slip44:118' }, + { asset: 'BASE.ETH', symbol: 'ETH', name: 'Base ETH', decimals: 18, + caip: 'eip155:8453/slip44:60' }, { asset: 'UNKNOWN.FOO', symbol: 'FOO' }, // unknown chain — should be filtered out ], }, @@ -147,7 +155,8 @@ const FIXTURE_ASSETS_FLAT = { // ── Quote parsing tests ───────────────────────────────────────────── describe('parseQuoteResponse', () => { - const baseParams = { fromAsset: 'BASE.ETH', toAsset: 'ETH.ETH', slippageBps: 300 } + // CAIP-only — Pioneer's Quote endpoint is the source of truth for routing. + const baseParams = { fromCaip: 'eip155:8453/slip44:60', toCaip: 'eip155:1/slip44:60', slippageBps: 300 } test('BASE → ETH: extracts memo from txParams', () => { const result = parseQuoteResponse(FIXTURE_BASE_TO_ETH_QUOTE, baseParams) @@ -206,39 +215,33 @@ describe('parseQuoteResponse', () => { 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 params = { fromCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', toCaip: 'eip155:1/slip44:60', 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 params = { fromCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', toCaip: 'eip155:1/slip44:60', 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 params = { fromCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', toCaip: 'eip155:1/slip44:60', 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 params = { fromCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', toCaip: 'eip155:1/slip44:60', 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 params = { fromCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', toCaip: 'eip155:1/slip44:60', 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) @@ -246,7 +249,7 @@ describe('parseQuoteResponse', () => { // 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 params = { fromCaip: 'eip155:1/slip44:60', toCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', slippageBps: 300 } const result = parseQuoteResponse(FIXTURE_MINIMAL_QUOTE, params) expect(result.memo).toBe('swap:ETH.ETH:0xdest') expect(result.inboundAddress).toBe('0xvault789') @@ -259,7 +262,7 @@ describe('parseQuoteResponse', () => { // Single object (not array) test('single object response: wraps in array and parses', () => { - const params = { fromAsset: 'ETH.ETH', toAsset: 'BTC.BTC', slippageBps: 300 } + const params = { fromCaip: 'eip155:1/slip44:60', toCaip: 'bip122:000000000019d6689c085ae165831e93/slip44:0', slippageBps: 300 } const result = parseQuoteResponse(FIXTURE_SINGLE_QUOTE, params) expect(result.expectedOutput).toBe('0.5') expect(result.memo).toBe('cf:swap') @@ -276,7 +279,20 @@ describe('parseQuoteResponse', () => { test('throws on missing output amount', () => { const badResp = { data: [{ quote: { inbound_address: '0x123' } }] } expect(() => parseQuoteResponse(badResp, baseParams)) - .toThrow('Quote response missing output amount') + .toThrow(/No quote output for/) + }) + + test('throws on zero output amount (Pioneer schema drift / no liquidity)', () => { + const badResp = { data: [{ quote: { buyAmount: '0', inbound_address: '0x123' } }] } + expect(() => parseQuoteResponse(badResp, baseParams)) + .toThrow(/No quote output for/) + }) + + test('accepts new Pioneer field names (expectedAmountOut, amount_out)', () => { + const camel = { data: [{ quote: { expectedAmountOut: '2.5', inbound_address: '0xv', memo: '=:ETH.ETH:0xdest' } }] } + expect(parseQuoteResponse(camel, baseParams).expectedOutput).toBe('2.5') + const snake = { data: [{ quote: { amount_out: '3.7', inbound_address: '0xv', memo: '=:ETH.ETH:0xdest' } }] } + expect(parseQuoteResponse(snake, baseParams).expectedOutput).toBe('3.7') }) test('throws on missing inbound address', () => { @@ -284,6 +300,155 @@ describe('parseQuoteResponse', () => { expect(() => parseQuoteResponse(badResp, baseParams)) .toThrow('Quote response missing inbound address') }) + + test('native THORChain RUNE deposit: missing inbound is OK (uses MsgDeposit)', () => { + // The check that previously string-compared `params.fromAsset === 'THOR.RUNE'` + // is now CAIP-driven. Pin the canonical CAIP so anyone who renames or moves + // the constant in swap-parsing.ts has to also update this test. + const RUNE_CAIP = 'cosmos:thorchain-mainnet-v1/slip44:931' + const resp = { data: [{ quote: { buyAmount: '1.0' } }] } + const params = { fromCaip: RUNE_CAIP, toCaip: 'eip155:1/slip44:60', slippageBps: 300 } + expect(() => parseQuoteResponse(resp, params)).not.toThrow() + }) + + test('native Mayachain CACAO deposit: missing inbound is OK (uses MsgDeposit)', () => { + const CACAO_CAIP = 'cosmos:mayachain-mainnet-v1/slip44:931' + const resp = { data: [{ quote: { buyAmount: '1.0' } }] } + const params = { fromCaip: CACAO_CAIP, toCaip: 'eip155:1/slip44:60', slippageBps: 300 } + expect(() => parseQuoteResponse(resp, params)).not.toThrow() + }) + + test('error message uses CAIPs (not THORChain asset strings)', () => { + // The "Unsupported THORChain chain" class of errors is gone — vault is + // CAIP-native — so error messages must reference CAIPs. + const params = { + fromCaip: 'eip155:1/slip44:60', + toCaip: 'eip155:10/erc20:0x9560e827af36c94d2ac33a39bce1fe78631088db', // VELO + slippageBps: 300, + } + const noOutput = { data: [{ quote: { inbound_address: '0xv' } }] } + expect(() => parseQuoteResponse(noOutput, params)) + .toThrow(/eip155:10\/erc20:0x9560/) + }) + + // ── Deposit-channel protocols (Chainflip, NEAR Intents EVM side) ─── + + test('Chainflip ETH→BTC: data="0x" creates deposit-channel relayTx (not rejected)', () => { + // The real Pioneer response for ETH→BTC when THORChain is offline: Chainflip + // via ShapeShift. Chainflip uses a deposit-channel model — the BTC destination + // was registered when the quote was created; the user just sends a plain ETH + // transfer to the deposit contract address. `data = '0x'` is intentional. + const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + const ethCaip = 'eip155:1/slip44:60' + const resp = { + data: [{ + integration: 'shapeshiftSwap', + quote: { + swapper: 'Chainflip', + buyAmount: '0.0002685', + txs: [{ txParams: { + to: '0xd054199c7c2d30a38cebae7a9c1ca238f932be80', + data: '0x', + value: '10000000000000000', + } }], + }, + }], + } + const result = parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: btcCaip, slippageBps: 300 }) + expect(result.relayTx).toBeDefined() + expect(result.relayTx!.data).toBe('0x') + expect(result.relayTx!.isDepositChannel).toBe(true) + expect(result.swapper).toBe('Chainflip') + }) + + test('NEAR Intents ETH→BTC: data="0x" also flagged as deposit-channel', () => { + const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + const ethCaip = 'eip155:1/slip44:60' + const resp = { + data: [{ + integration: 'shapeshift', + quote: { + swapper: 'NEAR Intents', + buyAmount: '0.002', + txs: [{ txParams: { data: '0x', to: '0xnear_deposit', value: '10000000000000000' } }], + }, + }], + } + const result = parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: btcCaip, slippageBps: 300 }) + expect(result.relayTx?.isDepositChannel).toBe(true) + }) + + test('Relay with data="0x" is NOT a deposit channel — no relayTx created', () => { + // Relay is a pure calldata protocol; empty calldata = malformed quote. + // Without isDepositChannel, the guard in buildRelaySwapTx will catch it. + const ethCaip = 'eip155:1/slip44:60' + const usdcCaip = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const resp = { + data: [{ + integration: 'shapeshift', + quote: { + swapper: 'Relay', + buyAmount: '100.0', + memo: 'MEMO', + inbound_address: '0xvault', + txs: [{ txParams: { data: '0x', to: '0xdest', value: '1000' } }], + }, + }], + } + // data='0x' with swapper=Relay → not a deposit channel, relayTx not created + const result = parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: usdcCaip, slippageBps: 100 }) + expect(result.relayTx).toBeUndefined() + // Memo path used instead + expect(result.memo).toBe('MEMO') + }) + + test('NEAR Intents BTC→ETH (UTXO source, no memo) — isMemolessTransfer fires', () => { + // The canonical case: BTC → ETH via NEAR Intents. Pioneer provides a BTC + // deposit address; no memo or calldata needed. Should parse successfully. + const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + const ethCaip = 'eip155:1/slip44:60' + const resp = { + data: [{ + integration: 'shapeshift', + quote: { + swapper: 'NEAR Intents', + buyAmount: '0.05', + inbound_address: 'bc1qnearintentsdeposit', + // No memo, no calldata — only deposit address (UTXO side) + txs: [{ txParams: { to: 'bc1qnearintentsdeposit' } }], + }, + }], + } + const result = parseQuoteResponse(resp, { fromCaip: btcCaip, toCaip: ethCaip, slippageBps: 300 }) + expect(result.inboundAddress).toBe('bc1qnearintentsdeposit') + expect(result.memo).toBe('') + expect(result.relayTx).toBeUndefined() + expect(result.swapper).toBe('NEAR Intents') + }) + + test('relayTx with real calldata to EVM destination is still accepted', () => { + // Relay ETH → USDC (same chain, EVM destination) should work fine. + const ethCaip = 'eip155:1/slip44:60' + const usdcCaip = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const resp = { + data: [{ + integration: 'shapeshift', + quote: { + swapper: 'Relay', + buyAmount: '100.0', + txs: [{ txParams: { + data: '0x12345678000000000000000000000000000000000000000000', + to: '0xrelayRouter', value: '1000000000000000', + chainId: 1, gasLimit: '300000', + } }], + }, + }], + } + const result = parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: usdcCaip, slippageBps: 300 }) + expect(result.relayTx).toBeDefined() + expect(result.relayTx!.isDepositChannel).toBeUndefined() + expect(result.relayTx!.data).toBe('0x12345678000000000000000000000000000000000000000000') + }) }) // ── Assets parsing tests ──────────────────────────────────────────── @@ -341,6 +506,41 @@ describe('parseAssetsResponse', () => { expect(unknown).toBeUndefined() }) + test('preserves token caip from pioneer-server (case-sensitive)', () => { + // pioneer-server's swap-config controller emits caip with lowercase + // contract for EVM tokens (CAIP-19 spec) — vault must NOT silently + // fall back to the native chain CAIP when raw.caip is present. + const assets = parseAssetsResponse(FIXTURE_ASSETS_RESPONSE) + const usdt = assets.find(a => a.symbol === 'USDT' && a.contractAddress) + expect(usdt).toBeTruthy() + expect(usdt!.caip).toBe('eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7') + expect(usdt!.caip).not.toBe('eip155:1/slip44:60') // would be the bug — token attached to native CAIP + }) + + test('drops malformed token assets (missing caip) instead of falling back to native', () => { + // If pioneer-server ever emits a token entry without caip, the previous + // `raw.caip || chainDef.caip` fallback would make the token quote against + // the chain's NATIVE CAIP — silent corruption. Defense: drop + warn. + const malformed = { + data: { + success: true, + data: { + assets: [ + { asset: 'ETH.ETH', symbol: 'ETH', decimals: 18, + caip: 'eip155:1/slip44:60' }, // native — fine + { asset: 'ETH.USDT-0xdAC17F958D2ee523a2206206994597C13D831ec7', + symbol: 'USDT', decimals: 6 }, // token without caip — should be dropped + ], + }, + }, + } + const assets = parseAssetsResponse(malformed) + expect(assets.length).toBe(1) + expect(assets[0].asset).toBe('ETH.ETH') + // The token was dropped, not silently keyed under native CAIP. + expect(assets.find(a => a.symbol === 'USDT')).toBeUndefined() + }) + test('parses flat array response (single unwrap)', () => { const assets = parseAssetsResponse(FIXTURE_ASSETS_FLAT) expect(assets.length).toBe(2) diff --git a/projects/keepkey-vault/__tests__/swap-revert.test.ts b/projects/keepkey-vault/__tests__/swap-revert.test.ts new file mode 100644 index 00000000..3967afa0 --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-revert.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for decideRevertOutcome — the pure receipt → swap-status mapping + * extracted from swap-tracker.ts:detectEvmRevert. + * + * The original detectEvmRevert mixed RPC fetches, in-memory mutation, DB writes, + * and decision logic. Splitting the decision out lets us pin the exact boundary: + * an EVM receipt with status=false must flip the swap to "failed" with a + * user-facing error, but only when the swap isn't already terminal. + * + * Run: bun test __tests__/swap-revert.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { decideRevertOutcome } from '../src/shared/swap-revert' + +describe('decideRevertOutcome', () => { + const revertedReceipt = { status: false, blockNumber: 12345 } + const successReceipt = { status: true, blockNumber: 12345 } + + test('reverted receipt + pending swap → failed decision', () => { + const d = decideRevertOutcome('pending', revertedReceipt) + expect(d).not.toBeNull() + expect(d!.status).toBe('failed') + expect(d!.blockNumber).toBe(12345) + expect(d!.error).toMatch(/reverted on-chain/i) + }) + + test('reverted receipt + confirming swap → failed decision', () => { + // The "lied to with waiting for confirmations" bug: tx was confirming + // status from Pioneer perspective, but on-chain it reverted. + const d = decideRevertOutcome('confirming', revertedReceipt) + expect(d).not.toBeNull() + expect(d!.status).toBe('failed') + }) + + test('successful receipt → null (let normal pipeline take over)', () => { + expect(decideRevertOutcome('pending', successReceipt)).toBeNull() + expect(decideRevertOutcome('confirming', successReceipt)).toBeNull() + }) + + test('null receipt (not mined yet) → null', () => { + expect(decideRevertOutcome('pending', null)).toBeNull() + }) + + test('idempotent: already failed → null', () => { + expect(decideRevertOutcome('failed', revertedReceipt)).toBeNull() + }) + + test('idempotent: already completed → null', () => { + // Even an apparent revert receipt can't unset a completed swap — would be + // a state regression, so the pure decision must refuse to act. + expect(decideRevertOutcome('completed', revertedReceipt)).toBeNull() + }) + + test('idempotent: refunded → null', () => { + expect(decideRevertOutcome('refunded', revertedReceipt)).toBeNull() + }) + + test('error message names the common causes', () => { + // The user-facing copy is part of the contract — UX regressions here + // (e.g. dropping the allowance/slippage hints) should be loud. + const d = decideRevertOutcome('pending', revertedReceipt) + expect(d!.error).toMatch(/allowance/i) + expect(d!.error).toMatch(/slippage/i) + expect(d!.error).toMatch(/gas spent/i) + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts b/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts new file mode 100644 index 00000000..c8690c0e --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts @@ -0,0 +1,221 @@ +/** + * Tests for assessAvailability — the static client-side support matrix that + * drives the asset-picker dialog's per-row availability badge. + * + * The matrix is intentionally narrow: known-positive cases hardcoded, + * everything else falls into `unknown` (try a quote) or `unsupported_chain`. + * These tests pin both kinds of boundary so that future matrix edits can't + * silently regress availability hints. + * + * Run: bun test __tests__/swap-support-matrix.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { assessAvailability } from '../src/shared/swap-support-matrix' + +const BTC = 'bip122:000000000019d6689c085ae165831e93/slip44:0' +const ETH = 'eip155:1/slip44:60' +const AVAX = 'eip155:43114/slip44:60' +const POLYGON = 'eip155:137/slip44:60' +const USDT_ETH = 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' +const USDC_ETH = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +const USDC_BASE = 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + +const PEPE_ETH = 'eip155:1/erc20:0x6982508145454ce325ddbe47a25d4ec3d2311933' // long-tail ERC-20 +const COSMOS = 'cosmos:cosmoshub-4/slip44:118' +const RUNE = 'cosmos:thorchain-mainnet-v1/slip44:931' +const MONAD = 'eip155:99999/slip44:60' // truly-unknown EVM (Monad mainnet eip155:143 is now supported) +const TRON = 'tron:27Lqcw/slip44:195' +const TON = 'ton:-239/slip44:607' + +describe('assessAvailability — natives', () => { + test('BTC native is swappable on THORChain + Mayachain + ChainFlip', () => { + const a = assessAvailability(BTC) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + expect(a.providers).toContain('mayachain') + expect(a.providers).toContain('chainflip') + }) + + test('ETH native is swappable on every provider in our matrix', () => { + const a = assessAvailability(ETH) + expect(a.status).toBe('swappable') + expect(a.providers.length).toBeGreaterThanOrEqual(4) + }) + + test('AVAX native is swappable on THORChain + Relay + 0x', () => { + const a = assessAvailability(AVAX) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + expect(a.providers).toContain('relay') + expect(a.providers).toContain('zeroex') + }) + + test('POLYGON native: aggregators only (no THORChain native pool)', () => { + const a = assessAvailability(POLYGON) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('relay') + expect(a.providers).toContain('zeroex') + expect(a.providers).not.toContain('thorchain') + }) + + test('Cosmos hub native is swappable via THORChain', () => { + const a = assessAvailability(COSMOS) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + }) + + test('RUNE swappable via THORChain + Mayachain', () => { + const a = assessAvailability(RUNE) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + expect(a.providers).toContain('mayachain') + }) + + test('Unknown EVM chain (truly unmapped chainId) → unsupported_chain', () => { + const a = assessAvailability(MONAD) + expect(a.status).toBe('unsupported_chain') + expect(a.providers).toEqual([]) + expect(a.reason).toMatch(/not currently supported/i) + }) + + test('Monad mainnet (eip155:143) → swappable via Relay + ShapeShift', () => { + // Verified live 2026-05 by probing pioneer-server's /quote endpoint. + const a = assessAvailability('eip155:143/slip44:60') + expect(a.status).toBe('swappable') + expect(a.providers).toContain('relay') + expect(a.providers).toContain('shapeshift') + }) + + test('Long-tail EVMs (Berachain, Sonic, Mode, Manta) → swappable via Relay', () => { + for (const caip of [ + 'eip155:80094/slip44:60', // Berachain + 'eip155:146/slip44:60', // Sonic + 'eip155:34443/slip44:60', // Mode + 'eip155:169/slip44:60', // Manta + ]) { + const a = assessAvailability(caip) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('relay') + } + }) + + test('TRON native → swappable via THORChain (verified live in pioneer-server)', () => { + // pioneer-server's ENABLED_ASSETS_V1 lists tron:0x2b6653dc/slip44:195 + // (TRX) so TRON IS routable. Earlier matrix omitted it; regression bug. + const a = assessAvailability(TRON) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + }) + + test('TRON native (alternate base58 encoding) → swappable', () => { + // pioneer-discovery emits tron:27Lqcw (base58 of the genesis hash) while + // pioneer-server uses tron:0x2b6653dc (hex). normalizeChainCaip2 maps + // both to canonical so TRON entries don't fragment. + const tronAlt = 'tron:27Lqcw/slip44:195' + const a = assessAvailability(tronAlt) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + }) + + test('TON native → unsupported_chain', () => { + const a = assessAvailability(TON) + expect(a.status).toBe('unsupported_chain') + }) +}) + +describe('assessAvailability — well-known stablecoins', () => { + test('USDT-on-Ethereum is swappable (Relay + 0x + THORChain)', () => { + const a = assessAvailability(USDT_ETH) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('relay') + expect(a.providers).toContain('zeroex') + expect(a.providers).toContain('thorchain') + }) + + test('USDC-on-Ethereum is swappable', () => { + const a = assessAvailability(USDC_ETH) + expect(a.status).toBe('swappable') + expect(a.providers.length).toBeGreaterThanOrEqual(2) + }) + + test('USDC-on-Base is swappable via aggregators (no THORChain pool)', () => { + const a = assessAvailability(USDC_BASE) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('relay') + expect(a.providers).toContain('zeroex') + expect(a.providers).not.toContain('thorchain') + }) + + test('TRON USDT (canonical hex encoding) → swappable via THORChain', () => { + const usdtTron = 'tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + const a = assessAvailability(usdtTron) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + }) + + test('TRON USDT (alternate base58 encoding) → swappable (encoding normalized)', () => { + const usdtTronAlt = 'tron:27Lqcw/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + const a = assessAvailability(usdtTronAlt) + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + }) + + test('BSC USDT — both /erc20: and /bep20: forms resolve to swappable', () => { + // pioneer-server's quote endpoint only routes BSC tokens under /erc20: + // (verified live: /bep20: returns "No quotes available"). pioneer-discovery + // emits BSC tokens as /bep20:. STABLECOIN_TOKENS is keyed on /erc20: per + // pioneer-server convention; assessAvailability folds /bep20: into /erc20: + // so picker rows from discovery aren't stuck in `unknown`. + const usdtErc = 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955' + const usdtBep = 'eip155:56/bep20:0x55d398326f99059ff775485246999027b3197955' + expect(assessAvailability(usdtErc).status).toBe('swappable') + expect(assessAvailability(usdtBep).status).toBe('swappable') + expect(assessAvailability(usdtBep).providers).toContain('thorchain') + }) + + test('BSC USDC — /bep20: form resolves to swappable (regression)', () => { + const usdcBep = 'eip155:56/bep20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d' + expect(assessAvailability(usdcBep).status).toBe('swappable') + }) + + test('Random BSC bep20 token still falls through to unknown (try-quote)', () => { + // Bug check: namespace fold must NOT make every BSC bep20 row swappable + // — only the ones explicitly in the stablecoin set. + const random = 'eip155:56/bep20:0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + expect(assessAvailability(random).status).toBe('unknown') + }) +}) + +describe('assessAvailability — long-tail tokens', () => { + test('Random ERC-20 on Ethereum → unknown (try a quote)', () => { + // PEPE isn't in our hardcoded stablecoin set, but Ethereum is covered by + // Relay/0x — most ERC-20s actually work, so we say "unknown" instead of + // falsely flagging it as unsupported. + const a = assessAvailability(PEPE_ETH) + expect(a.status).toBe('unknown') + expect(a.providers).toEqual([]) + expect(a.reason).toMatch(/try a quote/i) + }) + + test('Token on an unsupported chain → unsupported_chain', () => { + const tokenOnMonad = 'eip155:10143/erc20:0x1234567890abcdef1234567890abcdef12345678' + const a = assessAvailability(tokenOnMonad) + expect(a.status).toBe('unsupported_chain') + }) +}) + +describe('assessAvailability — defensive', () => { + test('empty caip → unsupported_chain (graceful)', () => { + const a = assessAvailability('') + expect(a.status).toBe('unsupported_chain') + expect(a.providers).toEqual([]) + }) + + test('chain-only caip (no slash) is treated as native and assessed', () => { + // bip122:00000... without /slip44:0 — defensive: matrix lookup still + // proceeds and either matches or falls into unsupported_chain. + const a = assessAvailability('bip122:000000000019d6689c085ae165831e93') + expect(a.status).toBe('swappable') + expect(a.providers).toContain('thorchain') + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-warnings.test.ts b/projects/keepkey-vault/__tests__/swap-warnings.test.ts new file mode 100644 index 00000000..3d218677 --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-warnings.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for the pure swap-warning helpers in src/shared/swap-warnings.ts. + * + * These warnings drive UI surfacing decisions that we got wrong before: + * - dust-fee tier (>10% / >25% loss) — needs concrete examples to lock in + * - high-slippage check used to read only the quote's market slippage, + * missing the user's tolerance (so 5% tolerance + 0.2% market = silent risk) + * + * Run: bun test __tests__/swap-warnings.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { + computeDustWarning, + computeEffectiveSlippageBps, + shouldWarnHighSlippage, + DUST_FEE_WARNING_PCT, + DUST_FEE_SEVERE_PCT, + HIGH_SLIPPAGE_PCT, +} from '../src/shared/swap-warnings' + +describe('computeDustWarning', () => { + test('profitable swap (loss < threshold) → null', () => { + // 100 USD in, 96 USD out → 4% loss, below 10% threshold + const w = computeDustWarning({ inAmount: 100, outAmount: 96, fromPriceUsd: 1, toPriceUsd: 1 }) + expect(w).toBeNull() + }) + + test('exactly at warning threshold → null (strict <)', () => { + // Loss exactly DUST_FEE_WARNING_PCT must NOT fire — only >= threshold. + // The implementation uses `< DUST_FEE_WARNING_PCT` for early-out, so + // strictly equal slips through. Documents the boundary explicitly. + const w = computeDustWarning({ inAmount: 100, outAmount: 90, fromPriceUsd: 1, toPriceUsd: 1 }) + expect(w).not.toBeNull() + expect(w!.lossPct).toBeCloseTo(10, 6) + expect(w!.severe).toBe(false) + }) + + test('mid-tier loss (~15%) → warning, not severe', () => { + const w = computeDustWarning({ inAmount: 100, outAmount: 85, fromPriceUsd: 1, toPriceUsd: 1 }) + expect(w).not.toBeNull() + expect(w!.severe).toBe(false) + expect(w!.lossPct).toBeCloseTo(15, 6) + expect(w!.lostUsd).toBeCloseTo(15, 6) + expect(w!.inUsd).toBe(100) + expect(w!.recommendedMinUsd).toBe(400) // ceil(100 * 4) + }) + + test('severe loss (>25%) → severe=true', () => { + // The "$2 BTC swap" scenario from the production bug — 30% loss + const w = computeDustWarning({ inAmount: 100, outAmount: 70, fromPriceUsd: 1, toPriceUsd: 1 }) + expect(w).not.toBeNull() + expect(w!.severe).toBe(true) + expect(w!.lossPct).toBeCloseTo(30, 6) + }) + + test('zero input USD → null (avoid divide-by-zero)', () => { + const w = computeDustWarning({ inAmount: 0, outAmount: 0, fromPriceUsd: 50000, toPriceUsd: 1 }) + expect(w).toBeNull() + }) + + test('zero output USD → null (no quote yet, do not warn)', () => { + const w = computeDustWarning({ inAmount: 1, outAmount: 0, fromPriceUsd: 50000, toPriceUsd: 1 }) + expect(w).toBeNull() + }) + + test('NaN inputs → null', () => { + expect(computeDustWarning({ inAmount: NaN, outAmount: 1, fromPriceUsd: 1, toPriceUsd: 1 })).toBeNull() + expect(computeDustWarning({ inAmount: 1, outAmount: NaN, fromPriceUsd: 1, toPriceUsd: 1 })).toBeNull() + expect(computeDustWarning({ inAmount: 1, outAmount: 1, fromPriceUsd: NaN, toPriceUsd: 1 })).toBeNull() + expect(computeDustWarning({ inAmount: 1, outAmount: 1, fromPriceUsd: 1, toPriceUsd: NaN })).toBeNull() + }) + + test('cross-asset prices: BTC → USDT dust scenario', () => { + // 0.00003 BTC @ $60k = $1.80 input + // 1.50 USDT @ $1 = $1.50 output → 16.7% loss → warning + const w = computeDustWarning({ inAmount: 0.00003, outAmount: 1.50, fromPriceUsd: 60000, toPriceUsd: 1 }) + expect(w).not.toBeNull() + expect(w!.severe).toBe(false) + expect(w!.lossPct).toBeGreaterThan(15) + expect(w!.lossPct).toBeLessThan(20) + }) + + test('thresholds match documented constants', () => { + expect(DUST_FEE_WARNING_PCT).toBe(10) + expect(DUST_FEE_SEVERE_PCT).toBe(25) + }) +}) + +describe('computeEffectiveSlippageBps', () => { + test('returns the larger of quote vs user', () => { + expect(computeEffectiveSlippageBps(20, 500)).toBe(500) + expect(computeEffectiveSlippageBps(500, 20)).toBe(500) + }) + + test('equal values → that value', () => { + expect(computeEffectiveSlippageBps(100, 100)).toBe(100) + }) + + test('clamps negatives to 0', () => { + expect(computeEffectiveSlippageBps(-50, 100)).toBe(100) + expect(computeEffectiveSlippageBps(50, -100)).toBe(50) + expect(computeEffectiveSlippageBps(-50, -100)).toBe(0) + }) + + test('NaN treated as 0', () => { + expect(computeEffectiveSlippageBps(NaN, 200)).toBe(200) + expect(computeEffectiveSlippageBps(200, NaN)).toBe(200) + expect(computeEffectiveSlippageBps(NaN, NaN)).toBe(0) + }) +}) + +describe('shouldWarnHighSlippage', () => { + test('the bug we know exists: tight quote + loose user setting → warns', () => { + // Pioneer reported 0.19% (19 bps) market slippage; user set 5% (500 bps) tolerance. + // Old check (quote only) → false. New check (max) → true. + expect(shouldWarnHighSlippage(19, 500)).toBe(true) + }) + + test('both low → no warning', () => { + expect(shouldWarnHighSlippage(50, 100)).toBe(false) // 1% + }) + + test('quote alone above threshold → warns', () => { + expect(shouldWarnHighSlippage(400, 100)).toBe(true) // 4% market quote + }) + + test('exactly at threshold → no warning (strictly greater)', () => { + // 300 bps = 3.00% — boundary check. Implementation uses `> HIGH_SLIPPAGE_PCT`. + expect(shouldWarnHighSlippage(300, 0)).toBe(false) + expect(shouldWarnHighSlippage(0, 300)).toBe(false) + }) + + test('just above threshold → warns', () => { + expect(shouldWarnHighSlippage(301, 0)).toBe(true) + expect(shouldWarnHighSlippage(0, 301)).toBe(true) + }) + + test('threshold matches documented constant', () => { + expect(HIGH_SLIPPAGE_PCT).toBe(3) + }) +}) diff --git a/projects/keepkey-vault/__tests__/ton-build.test.ts b/projects/keepkey-vault/__tests__/ton-build.test.ts new file mode 100644 index 00000000..aaa00d59 --- /dev/null +++ b/projects/keepkey-vault/__tests__/ton-build.test.ts @@ -0,0 +1,129 @@ +/** + * Pure-function tests for TON build/verify flow. + * + * These run under `bun test` without a device or a live TonCenter — + * they cover the tamper-detection path in /ton/finalize-transfer so the + * regression from the PR review (REST-layer accepts mutated _internal + * and silently signs over a different body) stays locked down. + */ +import { describe, test, expect } from 'bun:test' +import { buildTonTransfer, computeTonBodyHash } from '../src/bun/txbuilder/ton' + +// Raw "workchain:hex" addresses skip CRC16 validation (user-friendly base64 +// addresses embed a checksum and would break if drifted). All we need here +// is a 32-byte hash per endpoint; the cell math doesn't care what bytes. +const FROM_ADDR = '0:1111111111111111111111111111111111111111111111111111111111111111' +const SAFE_TO = '0:2222222222222222222222222222222222222222222222222222222222222222' + +describe('TON build → bodyHash round-trip', () => { + test('computeTonBodyHash matches build.bodyHash for a clean build', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + + const recomputed = computeTonBodyHash(build) + expect(recomputed).toBe(build.bodyHash) + }) + + test('computeTonBodyHash changes when _internal.amountNano is mutated', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const originalHash = build.bodyHash + + // Simulate a malicious client mutating _internal post-signing + const tampered = { ...build, _internal: { ...build._internal, amountNano: '9999999999' } } + const recomputed = computeTonBodyHash(tampered) + expect(recomputed).not.toBe(originalHash) + }) + + test('computeTonBodyHash changes when _internal.destHash is mutated', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const originalHash = build.bodyHash + + // Flip a single byte in the destination hash + const bytes = Buffer.from(build._internal.destHash, 'hex') + bytes[0] ^= 0x01 + const tampered = { ...build, _internal: { ...build._internal, destHash: bytes.toString('hex') } } + const recomputed = computeTonBodyHash(tampered) + expect(recomputed).not.toBe(originalHash) + }) + + test('computeTonBodyHash changes when seqno is mutated', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const tampered = { ...build, seqno: 8 } + expect(computeTonBodyHash(tampered)).not.toBe(build.bodyHash) + }) + + test('computeTonBodyHash changes when memo is added', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const tampered = { ...build, _internal: { ...build._internal, memo: 'stealth-memo' } } + expect(computeTonBodyHash(tampered)).not.toBe(build.bodyHash) + }) + + test('computeTonBodyHash throws on missing _internal', () => { + expect(() => computeTonBodyHash({ bodyHash: 'aa' } as any)).toThrow(/_internal/) + }) + + test('computeTonBodyHash throws on malformed destHash hex', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const bad = { ...build, _internal: { ...build._internal, destHash: 'not-hex' } } + expect(() => computeTonBodyHash(bad)).toThrow(/destHash/) + }) + + test('computeTonBodyHash throws on non-integer seqno', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const bad = { ...build, seqno: 'abc' as any } + expect(() => computeTonBodyHash(bad)).toThrow(/seqno/) + }) + + test('computeTonBodyHash throws on empty amountNano', () => { + const build = buildTonTransfer({ + fromAddress: FROM_ADDR, + to: SAFE_TO, + amountNano: '1000000000', + seqno: 7, + expireAt: 1_700_000_000, + }) + const bad = { ...build, _internal: { ...build._internal, amountNano: '' } } + expect(() => computeTonBodyHash(bad)).toThrow(/amountNano/) + }) +}) diff --git a/projects/keepkey-vault/__tests__/tron-max-send.test.ts b/projects/keepkey-vault/__tests__/tron-max-send.test.ts new file mode 100644 index 00000000..5b4a8c81 --- /dev/null +++ b/projects/keepkey-vault/__tests__/tron-max-send.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { buildTx } from '../src/bun/txbuilder' +import { CHAINS } from '../src/shared/chains' + +const tron = CHAINS.find(c => c.id === 'tron')! +const tronAddress = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax' +const usdtContract = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + +let originalFetch: typeof fetch + +beforeEach(() => { + originalFetch = globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('TRON max send', () => { + test('native TRX max sends balance minus the fee reserve', async () => { + let transactionRequest: any + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/wallet/getaccount')) { + return jsonResponse({ balance: 12_345_678 }) + } + if (url.endsWith('/wallet/createtransaction')) { + transactionRequest = JSON.parse(String(init?.body ?? '{}')) + return jsonResponse({ + txID: 'native-max', + raw_data: {}, + raw_data_hex: '0a00', + }) + } + throw new Error(`unexpected fetch: ${url}`) + }) as typeof fetch + + const result = await buildTx({}, tron, { + chainId: 'tron', + to: tronAddress, + amount: '0', + isMax: true, + fromAddress: tronAddress, + }) + + expect(transactionRequest.amount).toBe(11_245_678) + expect(result.unsignedTx.amount).toBe('11245678') + expect(result.fee).toBe('1.1') + }) + + test('TRC-20 max reserves one token base unit before encoding transfer amount', async () => { + let contractRequest: any + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/wallet/triggersmartcontract')) { + contractRequest = JSON.parse(String(init?.body ?? '{}')) + return jsonResponse({ + transaction: { + txID: 'trc20-max', + raw_data: {}, + raw_data_hex: '0a00', + }, + }) + } + throw new Error(`unexpected fetch: ${url}`) + }) as typeof fetch + + const result = await buildTx({}, tron, { + chainId: 'tron', + to: tronAddress, + amount: '0', + isMax: true, + fromAddress: tronAddress, + caip: `tron:0x2b6653dc/trc20:${usdtContract}`, + tokenBalance: '123.45', + tokenDecimals: 6, + }) + + expect(contractRequest.contract_address).toBe(usdtContract) + expect(contractRequest.function_selector).toBe('transfer(address,uint256)') + expect(BigInt(`0x${contractRequest.parameter.slice(64)}`)).toBe(123_449_999n) + expect(result.unsignedTx.tronGridTx.txID).toBe('trc20-max') + expect(result.fee).toBe('30') + }) +}) diff --git a/projects/keepkey-vault/__tests__/tron-memo-inject.test.ts b/projects/keepkey-vault/__tests__/tron-memo-inject.test.ts new file mode 100644 index 00000000..e1998412 --- /dev/null +++ b/projects/keepkey-vault/__tests__/tron-memo-inject.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for TRON memo injection into a TronGrid `triggersmartcontract` + * response. The injection MUST place the data field at canonical tag order + * position (field 10, before contract/11) — appending at the end produces a + * different sha256 than what TronGrid computes after canonicalization, so + * the device's signature would not verify at broadcast. + * + * No network calls — uses a captured real TronGrid response as a fixture. + */ +import { describe, test, expect } from 'bun:test' +import { injectTronMemo } from '../src/bun/txbuilder' +import protobuf from 'protobufjs/light' + +// Real TronGrid /wallet/triggersmartcontract response captured 2026-04-22 for +// USDT.transfer to a synthetic recipient (Bitcoin Genesis address as the +// 20-byte hash). Bytes were produced by TronGrid itself, so the +// canonicalization rules they apply are baked in. +const FIXTURE_TX = { + visible: true, + txID: '758e54c5cef88d9d98f9b31841e783559d10398d42c9a4825b33bba33bc42412', + raw_data: { + contract: [{ + parameter: { + value: { + data: 'a9059cbb000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c0000000000000000000000000000000000000000000000000000000001312d00', + owner_address: 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax', + contract_address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + }, + type_url: 'type.googleapis.com/protocol.TriggerSmartContract', + }, + type: 'TriggerSmartContract', + }], + ref_block_bytes: '5796', + ref_block_hash: '9376a6a3333f4719', + expiration: 1745353010000, + fee_limit: 30000000, + timestamp: 1745352952000, + }, + raw_data_hex: '0a02579622089376a6a3333f4719408082bab6db335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a15416e0617948fe030a7e4970f8389d4ad295f249b7e121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244a9059cbb000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c0000000000000000000000000000000000000000000000000000000001312d0070d3bcb6b6db3390018087a70e', +} + +const MEMO = '=:b:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + +describe('injectTronMemo', () => { + test('writes memo bytes into raw_data.data', async () => { + const result = await injectTronMemo({ ...FIXTURE_TX }, MEMO) + const decodedData = Buffer.from(result.raw_data.data, 'hex').toString('utf8') + expect(decodedData).toBe(MEMO) + }) + + test('updates raw_data_hex with field 10 in canonical position', async () => { + const result = await injectTronMemo({ ...FIXTURE_TX }, MEMO) + const newBytes = Buffer.from(result.raw_data_hex, 'hex') + + // Walk the new tx and confirm we see field 10 (data) BEFORE field 11 (contract) + const reader = new protobuf.Reader(newBytes) + const tags: number[] = [] + while (reader.pos < reader.len) { + const tag = reader.uint32() + tags.push(tag >>> 3) + reader.skipType(tag & 7) + } + const idx10 = tags.indexOf(10) + const idx11 = tags.indexOf(11) + expect(idx10).toBeGreaterThanOrEqual(0) + expect(idx11).toBeGreaterThanOrEqual(0) + expect(idx10).toBeLessThan(idx11) + }) + + test('preserves all original raw_data fields', async () => { + const result = await injectTronMemo({ ...FIXTURE_TX }, MEMO) + expect(result.raw_data.ref_block_bytes).toBe(FIXTURE_TX.raw_data.ref_block_bytes) + expect(result.raw_data.ref_block_hash).toBe(FIXTURE_TX.raw_data.ref_block_hash) + expect(result.raw_data.expiration).toBe(FIXTURE_TX.raw_data.expiration) + expect(result.raw_data.fee_limit).toBe(FIXTURE_TX.raw_data.fee_limit) + expect(result.raw_data.timestamp).toBe(FIXTURE_TX.raw_data.timestamp) + // The contract array (smart contract calldata) must be intact byte-for-byte + expect(result.raw_data.contract).toEqual(FIXTURE_TX.raw_data.contract as any) + }) + + test('recomputes txID as sha256(new raw_data_hex)', async () => { + const result = await injectTronMemo({ ...FIXTURE_TX }, MEMO) + const newBytes = Buffer.from(result.raw_data_hex, 'hex') + const expectedTxID = Buffer.from(await crypto.subtle.digest('SHA-256', newBytes)).toString('hex') + expect(result.txID).toBe(expectedTxID) + }) + + test('produced txID differs from original', async () => { + const result = await injectTronMemo({ ...FIXTURE_TX }, MEMO) + expect(result.txID).not.toBe(FIXTURE_TX.txID) + }) + + test('different memos produce different txIDs', async () => { + const a = await injectTronMemo({ ...FIXTURE_TX }, '=:b:addr1') + const b = await injectTronMemo({ ...FIXTURE_TX }, '=:b:addr2') + expect(a.txID).not.toBe(b.txID) + }) + + test('handles maximum THORChain memo length (250 bytes)', async () => { + const longMemo = '=:b:' + 'a'.repeat(246) // 250 bytes total + const result = await injectTronMemo({ ...FIXTURE_TX }, longMemo) + const decoded = Buffer.from(result.raw_data.data, 'hex').toString('utf8') + expect(decoded).toBe(longMemo) + expect(decoded.length).toBe(250) + }) + + test('does not mutate the input object', async () => { + const input = JSON.parse(JSON.stringify(FIXTURE_TX)) + const inputSnapshot = JSON.stringify(input) + await injectTronMemo(input, MEMO) + expect(JSON.stringify(input)).toBe(inputSnapshot) + }) +}) diff --git a/projects/keepkey-vault/docs/API.md b/projects/keepkey-vault/docs/API.md index 432e5c09..a44de2f7 100644 --- a/projects/keepkey-vault/docs/API.md +++ b/projects/keepkey-vault/docs/API.md @@ -93,7 +93,7 @@ An HTTP API for external applications (dApps, SDKs, CLI tools). Disabled by defa | Method | Params | Response | Description | |--------|--------|----------|-------------| -| `getBalances` | void | `ChainBalance[]` | Fetch all chain balances via Pioneer | +| `getBalances` | `{ forceRefresh?: boolean }` | `ChainBalance[]` | Fetch all chain balances via Pioneer; forceRefresh bypasses cache (default false) | | `getBalance` | `{ chainId }` | `ChainBalance` | Fetch single chain balance | | `buildTx` | `BuildTxParams` | `BuildTxResult` | Build unsigned transaction | | `broadcastTx` | `{ chainId, signedTx }` | `BroadcastResult` | Broadcast signed transaction | diff --git a/projects/keepkey-vault/docs/handoff-signing-history.md b/projects/keepkey-vault/docs/handoff-signing-history.md new file mode 100644 index 00000000..b749b14c --- /dev/null +++ b/projects/keepkey-vault/docs/handoff-signing-history.md @@ -0,0 +1,81 @@ +# Handoff: Signing History over REST + +## TL;DR + +Signing history is now available via authenticated REST. Use it to debug the 7.14 EIP-712 regression (see [incident-7.14-eip712-regression.md](incident-7.14-eip712-regression.md)) — and any future signing regression — without UI involvement. + +## Endpoints + +Both require auth via `Authorization: Bearer ` (paired-app API key from `POST /auth/pair`, same posture as `/api/portfolio/:id`). During a passphrase / hidden-wallet session, both endpoints return empty / 404 to prevent leaking standard-wallet audit history across sessions. + +### `GET /api/v1/activity` + +List recent signed/broadcast/swap operations, newest first. Filterable. + +Query parameters (all optional): + +| Param | Example | Notes | +|----------------|------------------------------------|----------------------------------------------------| +| `route` | `/eth/sign-typed-data` | Exact REST route match | +| `activityType` | `sign` \| `broadcast` \| `swap` | What was logged | +| `txid` | `0xabc…` | Exact match | +| `chain` | `ETH` | Chain symbol | +| `since` | `1714000000000` | Unix ms, inclusive lower bound | +| `until` | `1714086400000` | Unix ms, inclusive upper bound | +| `limit` | `50` (default `100`, max `500`) | | +| `offset` | `0` | For pagination | + +Response: +```json +{ "entries": [{ "id": 423, "method": "POST", "route": "/eth/sign-typed-data", "timestamp": 1714000000000, "durationMs": 4231, "status": 200, "appName": "uniswap.org", "requestBody": { "addressNList": [...], "typedData": {...} }, "responseBody": { "signature": "0x..." }, "chain": "ETH", "activityType": "sign" }, ...], "count": 1 } +``` + +### `GET /api/v1/activity/:id` + +Single entry with full request/response bodies. Returns 404 if not found, 400 on bad id. + +## Workflow: debug a failing sign + +1. Trigger the failing operation on the vault. (Approve on device.) +2. Pull the most recent `eth/sign-typed-data` entry: + ```bash + curl -s -H "Authorization: Bearer $KEEPKEY_API_KEY" \ + 'http://localhost:1646/api/v1/activity?route=/eth/sign-typed-data&limit=1' | jq + ``` +3. The full `requestBody.typedData` (domain + types + primaryType + message) and `responseBody.signature` are inline. Hand off to `tests/evm-eip712/uniswap-permit-prod.js` (offline half) to recover the address. +4. If the signature doesn't recover to the device's ETH address, the vault produced a bad sig. Diff host-computed digest vs firmware to localize. + +> **Note on what is persisted in `responseBody`.** The signature itself is preserved verbatim (it's small and we need it for offline replay). Larger signed-output blobs — `serialized` / `serializedTx` / `signedTx` / `signed` / `signedPayload` — are still stripped to `[trimmed]` in the audit log to keep rows compact. Those are derivable from request body + signature, so the offline replay path doesn't need them from the audit log. + +## What is and isn't logged + +**Logged** (in the `api_log` SQLite table): +- All REST sign endpoints (`/eth/sign*`, `/utxo/sign*`, `/cosmos/sign*`, `/solana/sign*`, etc.) +- RPC `broadcastTx` operations (txid + chain) +- RPC `executeSwap` operations +- Body sizes are not currently truncated — full typed data and full Solana message bytes are persisted as JSON. + +**Not logged** (intentional — privacy): +- Anything originating from a passphrase / hidden-wallet session. The `engine.isPassphraseWallet` guard at `src/bun/index.ts:584` skips DB writes for those, and the REST read endpoints additionally refuse to serve cached standard-wallet history while a passphrase session is active. +- Read-only ops (address derivation, getFeatures, etc.) — they go through the same `onApiLog` callback but aren't tagged with `activityType`, so they'll appear in unfiltered `findApiLogs` but are filtered out by `activityType=sign`. + +## Limits & retention + +- Ring buffer: max 5000 rows. Pruned probabilistically (~1% of inserts trigger a `DELETE … WHERE id NOT IN (… LIMIT 5000)`). +- Indexed on `timestamp DESC` and `activity_type`. + +## Where the code lives + +| Concern | File | +|----------------------|-----------------------------------| +| Schema / helpers | `src/bun/db.ts` | +| Logging hook | `src/bun/index.ts` (`onApiLog`) | +| REST routes | `src/bun/rest-api.ts` | +| Persisted shape | `src/shared/types.ts` (`ApiLogEntry`) | + +## Build-out ideas (not in this PR) + +- Add `requestBody.summary` / `responseBody.summary` fields server-side (e.g., extract `to`, `value`, `chainId` for ETH; `signerAddress`, `chain_id` for Cosmos) so list views don't have to download full payloads. Keep full body on `/:id`. +- Add a CSV / JSONL export endpoint for offline replay against a reference signer. +- Add a `replay` flag on the entry view that reconstructs the exact device call (no signing, just shape verification). +- Tag entries by app fingerprint (origin + paired-app id) so per-app regressions are filterable. diff --git a/projects/keepkey-vault/docs/incident-7.14-eip712-regression.md b/projects/keepkey-vault/docs/incident-7.14-eip712-regression.md new file mode 100644 index 00000000..01de33b9 --- /dev/null +++ b/projects/keepkey-vault/docs/incident-7.14-eip712-regression.md @@ -0,0 +1,62 @@ +# Incident: 7.14 EIP-712 Signing Regression (Release Blocker) + +**Date opened:** 2026-04-28 +**Severity:** Release blocker +**Status:** Unresolved — repro path captured, root cause not yet identified +**Affected:** Vault EIP-712 signing path (Permit2 / Uniswap UniswapX surfaced first) +**Captured branch:** `feat/zcash-cli-submodule` (where the failing payloads were collected) + +## Symptom + +Production users report Uniswap permit signatures failing to verify. EIP-712 signatures produced by the vault on the captured device do not recover to the device's ETH address. + +## Evidence on disk + +- Captured payloads: `projects/keepkey-sdk/tests/fixtures/eip712-blobs.json` +- Test runner: `projects/keepkey-sdk/tests/evm-eip712/uniswap-permit-prod.js` + - Offline half: re-derives sig from captured `(domain, types, primaryType, message, signature)` → recovers expected address. + - Online half: re-signs the same blob via the live vault, recovers, and compares. + +**Offline check passes.** Captured Permit2 sig `0x44b8eec…` recovers cleanly to `0x141D9959…`. So the captured fixture is internally consistent — the vault was producing correct sigs at capture time. The regression is *between then and now*: the vault is now producing sigs that don't recover for the same logical input. + +## How to reproduce + +```bash +cd projects/keepkey-sdk +KEEPKEY_API_KEY="${KEEPKEY_API_KEY:?set KEEPKEY_API_KEY}" \ + node tests/evm-eip712/uniswap-permit-prod.js +``` + +Approve the prompts on the device. If the freshly-signed sig fails to recover to the device address, the test prints the expected `domainSeparator` / `structHash` / `digest` so they can be diffed against what firmware actually hashed. + +## Open hypotheses (in rough probability order) + +1. **BEX path/account mapping drift** — captured blobs include a counterparty address `0xe24A8f2ae82F6829ef277E59268111BEE54B5D3e`. If that's a different ETH account from the wallet's, the BEX may be mapping it to a different derivation path than the device actually uses for signing. (Open question for whoever has the device.) +2. **Extension not reloaded after `make build`** — Chrome unpacked extensions don't hot-swap. The user-reported "bug persists" log might be the un-reloaded build still emitting the old EIP-1193 path. Confirm via `chrome://extensions` → reload card before re-running. +3. **Domain/struct hash mismatch from a recent decoder change** — `eip712-decoder.ts` or the firmware-side hashing has drifted. The online half of the test prints the digest the host computed; comparing to firmware would confirm. +4. **Wallet bytes corruption** — typed-data canonicalization differs (e.g., chainId number/string, missing `salt`, types ordering). + +## Why this is now debuggable from REST + +Until this PR, only the React UI could read signed-payload history (via internal RPC). Everything was already being persisted in the `api_log` table — request body, response body, txid, chain, timestamp — but there was no way to pull it from outside the vault process. That made it impossible for an SDK test or external diagnostic script to fetch "the last failing `/eth/sign-typed-data` and its output" to compare. + +The new endpoints (`GET /api/v1/activity`, `GET /api/v1/activity/:id`) close that gap. See [handoff-signing-history.md](handoff-signing-history.md) for the workflow. + +## Privacy posture (preserved) + +- Passphrase wallets are not persisted to `api_log` (privacy guard in `index.ts:584`). Same guard protects the new REST surface — passphrase sessions yield empty history. +- Both new endpoints require `auth.requireAuth(req)` (paired-app API key), same as `/api/portfolio/:id`. + +## Next steps for the next session + +1. Trigger one failing sign on the live vault. +2. `curl` the new activity endpoint to retrieve full request/response bodies. +3. Compare the host-computed digest (printed by `uniswap-permit-prod.js`) against the firmware's actual hashing — that diff is the regression. +4. If digests match but sig still doesn't recover, the issue is in firmware ECDSA / RFC-6979 path — escalate to the firmware repo with the full payload from the audit log. + +## Pointers + +- Persistence: `src/bun/db.ts` — `api_log` schema, `insertApiLog`, `findApiLogs`, `getApiLogById` +- Logging hook: `src/bun/index.ts:580` (`onApiLog` callback) +- REST surface: `src/bun/rest-api.ts` — `/api/v1/activity` block +- Captured fixture / runner: `projects/keepkey-sdk/tests/evm-eip712/uniswap-permit-prod.js` diff --git a/projects/keepkey-vault/docs/pioneer-pending-swap-truth-spike.md b/projects/keepkey-vault/docs/pioneer-pending-swap-truth-spike.md new file mode 100644 index 00000000..027c9e5f --- /dev/null +++ b/projects/keepkey-vault/docs/pioneer-pending-swap-truth-spike.md @@ -0,0 +1,218 @@ +# Pioneer Pending Swap Truth Spike + +Date: 2026-05-12 + +## Scope + +This handoff is only for Pioneer `/api/v1/swaps/pending/{txHash}` truth handling after the Mayachain router quote fix. The router quote patch is working locally on `localhost:9001`; the remaining issue is status/outbound truth for existing Maya swaps. + +The endpoint must stop treating Maya Midgard terminal actions as stale reconstructed `pending` rows, and it must distinguish refunds from successful swaps. + +## Local Test Environment + +Base URL tested: + +```sh +export PIONEER_BASE='http://localhost:9001' +``` + +Swagger responded: + +```text +https://localhost:9001/api/v1 +``` + +Quote shape test passed for `ETH -> ZEC`: + +```json +{ + "integration": "mayachain", + "router": "0xe3985E6b61b814F7Cdb188766562ba71b446B46d", + "inbound": "0x6a16f961e24e6e90bd9f950f768dc42a7f305664", + "recipientAddress": "0xe3985E6b61b814F7Cdb188766562ba71b446B46d", + "routerAddress": "0xe3985E6b61b814F7Cdb188766562ba71b446B46d", + "vaultAddress": "0x6a16f961e24e6e90bd9f950f768dc42a7f305664", + "memo": "=:ZEC.ZEC:t1gwwyCfbRMyQdwo8xXrMGDj3ZqVjhsHWTh" +} +``` + +That confirms the original EOA-targeting bug is fixed in the local Pioneer runtime. + +## Current Failure Matrix + +| Inbound txid | Midgard truth | Local Pioneer result | Desired Pioneer result | +| --- | --- | --- | --- | +| `7CE15ACD233EA4DFEC386B45BBB347906E41E366D9C4DB95E735ED88F87BD42D` | `type=refund`, outbound refund `633F6EF365333E51CA5D315DAF787507663F6C8FC371C511C99D4B9266E5F6DD`, asset `ETH.ETH` | `status=completed`, outbound hash present | `status=refunded`, outbound refund hash present, refund reason preserved | +| `A9260E10AE66DF46C4EE4128A664B41736DBD845D07041F67DE278F9CEF25A46` | `type=swap`, `status=success`, outbound `17AB8000BBD3CB951C83F917C5A93282C259F281CC284FBC5665BB226089609A`, asset `ETH.USDC-...` | `status=completed`, outbound hash present | OK, or normalized to the API's chosen terminal success status | +| `B5D885DD95C46149909619CB56D218652FC4F217FA7BAA1ED8D7CC2BFB2897AE` | `type=swap`, `status=success`, outbound `ACCB9E7230252E2440BEE46173CA347B73EF10190E2B6766D6644D5BA28C708C`, asset `ZEC.ZEC` | `status=pending`, outbound hash null | `status=completed`, outbound hash present | + +The status calls needed a 60 second curl timeout. A 20 second timeout produced no response body. That is a separate latency issue worth tracking, but correctness comes first. + +## Repro Commands + +Quote shape: + +```sh +curl -sS --max-time 20 -X POST "$PIONEER_BASE/api/v1/quote" \ + -H 'Content-Type: application/json' \ + -d '{ + "sellAsset":"eip155:1/slip44:60", + "sellAmount":"0.01", + "buyAsset":"bip122:00040fe8ec8471911baa1db1266ea15d/slip44:133", + "recipientAddress":"t1gwwyCfbRMyQdwo8xXrMGDj3ZqVjhsHWTh", + "senderAddress":"0x141d9959cae3853b035000490c03991eb70fc4ac", + "slippage":1 + }' | jq '.[] | { + integration, + swapper:.quote.swapper, + router:.quote.router, + inbound:(.quote.inbound_address // .quote.inboundAddress), + recipientAddress:.quote.txs[0].txParams.recipientAddress, + routerAddress:.quote.txs[0].txParams.routerAddress, + vaultAddress:.quote.txs[0].txParams.vaultAddress, + memo:.quote.txs[0].txParams.memo + }' +``` + +Pending-swap truth: + +```sh +for tx in \ + 7CE15ACD233EA4DFEC386B45BBB347906E41E366D9C4DB95E735ED88F87BD42D \ + A9260E10AE66DF46C4EE4128A664B41736DBD845D07041F67DE278F9CEF25A46 \ + B5D885DD95C46149909619CB56D218652FC4F217FA7BAA1ED8D7CC2BFB2897AE +do + curl -sS --max-time 60 "$PIONEER_BASE/api/v1/swaps/pending/$tx?rescan=true" \ + | jq '{ + txHash, + status, + integration, + sellAsset, + buyAsset, + outboundTxHash:(.thorchainData.outboundTxHash // .mayachainData.outboundTxHash // .outboundTxHash), + updatedAt, + error + }' +done +``` + +Midgard ground truth: + +```sh +curl -sS 'https://midgard.mayachain.info/v2/actions?txid=B5D885DD95C46149909619CB56D218652FC4F217FA7BAA1ED8D7CC2BFB2897AE' \ + | jq '{ + count, + type:.actions[0].type, + status:.actions[0].status, + inTx:.actions[0].in[0].txID, + outTx:.actions[0].out[0].txID, + outAsset:.actions[0].out[0].coins[0].asset, + outAmount:.actions[0].out[0].coins[0].amount + }' +``` + +## Implementation Plan + +### 1. Add a Maya Midgard Classifier Helper + +Add a small pure helper in Pioneer near the pending-swap lookup code, or in a reusable swap-status module: + +```ts +type MayaActionClassification = + | { status: 'pending'; outboundTxHash?: undefined; outboundAsset?: undefined; outboundAmountBaseUnits?: undefined; refundReason?: undefined } + | { status: 'completed'; outboundTxHash: string; outboundAsset: string; outboundAmountBaseUnits: string; refundReason?: undefined } + | { status: 'refunded'; outboundTxHash: string; outboundAsset: string; outboundAmountBaseUnits: string; refundReason?: string } + | { status: 'unknown' } +``` + +Rules: + +- Query `https://midgard.mayachain.info/v2/actions?txid=`. +- If no actions are returned, classify `unknown`. +- If `action.type === 'refund'`: + - `status = 'refunded'` unless the action itself is still pending. + - outbound hash is `action.out[0].txID`. + - outbound asset and amount come from `action.out[0].coins[0]`. + - refund reason comes from `action.metadata.refund.reason`, after stripping the `MidgardBadUTF8EncodedBase64:` prefix if present. +- If `action.type === 'swap'`: + - if `action.status === 'success'` and `action.out[0]` exists, classify terminal completed. + - if no outbound exists yet, classify pending. +- Do not infer outbound chain from the user's intended `buyAsset`; use the actual Midgard outbound coin asset. + +### 2. Use The Classifier In `GET /swaps/pending/{txHash}` + +When the row is Mayachain, or when the txid is found in Maya Midgard: + +- Run the classifier during `?rescan=true`. +- Also run it for stale cached rows whose status is `pending` and integration is `mayachain`. +- If classifier returns terminal truth, overwrite the reconstructed row fields before returning. + +Persist at least: + +- `status` +- outbound tx hash +- outbound asset CAIP/symbol/network where possible +- outbound amount base units and display amount +- refund reason for refunds +- `updatedAt` + +### 3. Fix Stale Pending Overwrite + +The `B5D885...` response proves the endpoint can reconstruct the sell/buy assets but does not replace the stale pending status with Midgard's terminal outbound. The overwrite path must not exit early just because a Mongo row already exists. + +Expected behavior: + +- Existing row + `?rescan=true` means "trust current chain/protocol truth over cached status." +- Existing pending Mayachain row with Midgard success should become completed. +- Existing pending Mayachain row with Midgard refund should become refunded. +- Existing completed row with Midgard refund should become refunded. + +### 4. Normalize Status Vocabulary + +Swagger has historically mentioned `success`, while the local endpoint returns `completed`. + +Pick one canonical API status and make it consistent: + +- If keeping current endpoint behavior, use `completed`. +- If aligning to existing schema enums, use `success`. + +Vault can map either, but Pioneer should not mix `success`, `completed`, and `fullfilled` for the same state in different paths. + +### 5. Add Regression Tests + +Use fixtures copied from Vault #149 or fetched snapshots: + +- `maya-refund-eth-to-zec-7ce1.json` +- `maya-completed-zec-to-usdc-a926.json` +- `maya-completed-eth-to-zec-b5d885.json` + +Tests should assert: + +- `7CE15...` classifies as refunded with outbound `633F6E...`. +- `A9260...` classifies as completed/success with outbound `17AB80...`. +- `B5D885...` classifies as completed/success with outbound `ACCB9E...`. +- An existing pending row is overwritten on `rescan=true`. +- An existing completed row is overwritten to refunded when Midgard says refund. + +## Acceptance Criteria + +Local `localhost:9001` should return: + +```text +7CE15...BD42D -> refunded, outbound 633F6EF365333E51CA5D315DAF787507663F6C8FC371C511C99D4B9266E5F6DD +A9260...25A46 -> completed/success, outbound 17AB8000BBD3CB951C83F917C5A93282C259F281CC284FBC5665BB226089609A +B5D885...897AE -> completed/success, outbound ACCB9E7230252E2440BEE46173CA347B73EF10190E2B6766D6644D5BA28C708C +``` + +The endpoint should respond under 20 seconds for these three known txids. + +## Vault Dependency + +Vault PR #149 already protects the UI by asking Maya Midgard directly and rendering terminal truth locally. This Pioneer work is still needed because: + +- external clients use `/swaps/pending/{txHash}` as the canonical status endpoint; +- Vault debug/audit views compare local truth with Pioneer truth; +- stale Pioneer pending rows create confusing tracker output and make completed swaps look stuck. + +Once Pioneer passes the acceptance curls above, Vault #149 can treat Pioneer as consistent with its local Midgard truth pass. + diff --git a/projects/keepkey-vault/docs/swap-provider-animations-handoff.md b/projects/keepkey-vault/docs/swap-provider-animations-handoff.md new file mode 100644 index 00000000..cb761ca5 --- /dev/null +++ b/projects/keepkey-vault/docs/swap-provider-animations-handoff.md @@ -0,0 +1,104 @@ +# Handoff — Swap Provider Animation Set + +**For:** content / motion-design agent +**Owner:** Vault swap UX +**Date:** 2026-05-10 + +## Project root + +All paths in this handoff are absolute. The vault project root is: + +``` +/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/ +``` + +## What this is for + +The "Confirm" screen of every swap renders a centerpiece route map (`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/v3/RouteMap.tsx`): from-token → **swapper centerpiece** → to-token, with a gradient curve and a gold value-dot animating along the path. The centerpiece is the visual identity of the swap — it tells the user *who* is routing their value. + +We need one branded looping animation per supported swapper. Until each lands we render the existing ShapeShift animation (`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/assets/swap/shifting.gif`) as the universal fallback. + +## Production spec (every animation) + +| Field | Value | +| --- | --- | +| Format | GIF (animated; transparent background or matched to the dark UI `#13131A` ink-2) | +| Dimensions | **256×256** (rendered at 128×128, supplied 2× for retina) | +| Frame rate | 24fps | +| Duration | 2.0–3.6s, **must seamlessly loop** | +| File size budget | ≤ 400 KB each (the centerpiece is on the swap-confirm critical path) | +| Safe area | Keep brand mark inside the inner 80% — the centerpiece is rendered inside an SVG circle clip-path | +| Motion vibe | Subtle continuous motion (rotation, pulse, particle drift). Avoid hard cuts or text — the SVG already prints the protocol name beneath | +| Color | Lead with the brand accent in the table below; respect each protocol's existing brand identity | + +Place finished files at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/assets/providers/animations/.gif` using the **key** column below. Once a file exists, the wire-up in `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/lib/swapper-animations.ts` is a one-line `import` swap (replace `shapeshiftFallback` with `import x from "../assets/providers/animations/.gif"`). + +## Provider list — the centerpiece roster + +Listed in priority order: native vault routes first (these are the largest swap volume), aggregator next, then the AMMs we surface as ShapeShift sub-routes. + +### Tier 1 — native vault integrations (always shown) + +| Key | Display label | Brand accent | Notes for the animation | +| --- | --- | --- | --- | +| `thorchain` | THORChain | `#33ff99` | Cross-chain native swaps. Existing brand: rune/diamond glyph, runic motifs. The dominant Bitcoin-side route. | +| `mayachain` | Maya | `#15c6c2` | Maya fork of Thor. Brand uses teal feathered/serpent motif. ZEC-on-Maya is a marquee route — keep the animation legible at small sizes. | + +### Tier 2 — ShapeShift aggregator + executor + +| Key | Display label | Brand accent | Notes | +| --- | --- | --- | --- | +| `shapeshift` | ShapeShift | `#386ff9` | The aggregator that surfaces most non-native routes. Branded fox / shifting motif. **Currently the universal fallback (`shifting.gif`) — when you produce a final ShapeShift centerpiece, drop it at `animations/shapeshift.gif` and update the fallback import.** | +| `relay` | Relay | `#ff5b22` | EVM cross-chain executor (Relay aggregator router on Ethereum). Brand: orange/red ripple wave. **This is what triggered the handoff — see screenshot showing a placeholder "REL" glyph.** | +| `0x` | 0x | `#000000` | EVM RFQ executor. Minimalist black/white brand. | +| `1inch` | 1inch | `#1f2937` | EVM aggregator. Brand: slate/red unicorn mark. | +| `cow` | CoW Swap | `#cb73a4` | Batch-auction executor. Brand: pink cow / "moo" motif. | +| `lifi` | LI.FI | `#f5b5fc` | Cross-chain aggregator. Brand: pink/lavender pyramid. | +| `chainflip` | Chainflip | `#46da93` | Native cross-chain (BTC/ETH/DOT/SOL). Brand: green chevrons. | +| `across` | Across | `#6cf9d8` | Cross-chain bridge. Brand: cyan/teal. | + +### Tier 3 — AMMs (surfaced via ShapeShift) + +| Key | Display label | Brand accent | Notes | +| --- | --- | --- | --- | +| `uniswap` | Uniswap | `#ff007a` | EVM AMM. Brand: pink unicorn. | +| `curve` | Curve | `#a4c8ff` | Stable-pair AMM. Brand: rainbow gradients on dark. | +| `balancer` | Balancer | `#536dfe` | Multi-asset AMM. Brand: blue B / petals. | +| `sushi` | Sushi | `#fa52a0` | EVM AMM. Brand: pink sushi/cat. | + +## Resolution rules (so the right animation plays) + +The picker normalizes the swapper string with `lowercase + strip [\s_.-]` and then matches: + +- `*thor*` → `thorchain` +- `*maya*` → `mayachain` +- `relay` / `relaylink` / `relayexchange` → `relay` +- `shapeshift` / `ss` / `ssquote` → `shapeshift` +- `0x` / `zeroex` → `0x` +- `uniswap` / starts with `univ` → `uniswap` +- `oneinch` / `1inch` → `1inch` +- `cow` / `cowswap` → `cow` +- `lifi` / `lifip` / `lifiquote` → `lifi` +- `chainflip` / `cf` → `chainflip` +- `across` → `across` +- `curve` / `curvefi` → `curve` +- `balancer` → `balancer` +- `sushi` / `sushiswap` → `sushi` + +Anything that doesn't match falls back to `shapeshift` (which itself currently falls back to `shifting.gif`). When a quote arrives with both `swapper` and `integration`, `swapper` wins — that's the actual executor. + +## Source-of-truth references + +- Provider brand registry (logos + accents): `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/ProviderBadge.tsx` +- Animation registry (where the new files plug in): `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/lib/swapper-animations.ts` +- Centerpiece component: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/v3/RouteMap.tsx` +- Call site (RouteMap usage in the Confirm screen): `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx` +- Existing fallback: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/assets/swap/shifting.gif` + +## Drop-in checklist + +1. Save GIF as `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/assets/providers/animations/.gif` using the table key. +2. In `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/lib/swapper-animations.ts`, replace `shapeshiftFallback` on that key's row with `import branded from "../assets/providers/animations/.gif"`, then reference `branded`. +3. Verify on a real swap (Confirm screen, rendered by `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx`) — the integration label below the centerpiece should match the animation; the gold value-dot should still travel through cleanly. + +That's the whole loop — once the assets exist the wire-up is mechanical. diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index 75995f9c..fc10fa08 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -56,7 +56,6 @@ export default { "com.apple.security.cs.allow-unsigned-executable-memory": true, "com.apple.security.cs.disable-library-validation": true, "com.apple.security.cs.allow-dyld-environment-variables": true, - "com.apple.security.device.camera": true, }, }, linux: { diff --git a/projects/keepkey-vault/entitlements.plist b/projects/keepkey-vault/entitlements.plist index f96a2825..92400e8e 100644 --- a/projects/keepkey-vault/entitlements.plist +++ b/projects/keepkey-vault/entitlements.plist @@ -2,15 +2,13 @@ + com.apple.security.cs.allow-dyld-environment-variables + com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation - com.apple.security.cs.allow-dyld-environment-variables - - com.apple.security.device.camera - diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 5af1e70b..2639b9bb 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.2.16", + "version": "1.3.6", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && electrobun build && bun scripts/patch-bundle.ts && electrobun dev", @@ -23,8 +23,10 @@ "@keepkey/hdwallet-keepkey-nodewebusb": "file:../../modules/hdwallet/packages/hdwallet-keepkey-nodewebusb", "@keepkey/proto-tx-builder": "file:../../modules/proto-tx-builder", "@pioneer-platform/pioneer-caip": "^9.27.10", - "@pioneer-platform/pioneer-client": "^11.0.0", + "@pioneer-platform/pioneer-client": "^11.1.0", "@pioneer-platform/pioneer-coins": "^11.0.0", + "@pioneer-platform/pioneer-discovery": "^10.0.9", + "bs58": "^6.0.0", "@walletconnect/core": "^2.23.9", "@walletconnect/jsonrpc-utils": "^1.0.8", "@walletconnect/types": "^2.23.9", diff --git a/projects/keepkey-vault/scripts/collect-externals.ts b/projects/keepkey-vault/scripts/collect-externals.ts index 1c2708d7..82d7c863 100644 --- a/projects/keepkey-vault/scripts/collect-externals.ts +++ b/projects/keepkey-vault/scripts/collect-externals.ts @@ -8,6 +8,29 @@ import { existsSync, mkdirSync, cpSync, readFileSync, rmSync, readdirSync, statSync } from 'node:fs' import { join, dirname, resolve } from 'node:path' +function directorySizeBytes(dirPath: string): number { + let total = 0 + try { + for (const entry of readdirSync(dirPath, { withFileTypes: true })) { + const fullPath = join(dirPath, entry.name) + try { + if (entry.isDirectory()) { + total += directorySizeBytes(fullPath) + } else if (entry.isFile()) { + total += statSync(fullPath).size + } + } catch {} + } + } catch {} + return total +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}M` + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}K` + return `${bytes}B` +} + // Only packages left external by scripts/bundle-backend.ts. // Everything else (ethers, pioneer, swagger, cosmjs, protobuf, @keepkey/*) // is pre-bundled into a single index.js. This reduces installed file count @@ -417,10 +440,7 @@ function cleanNativeArtifacts(dirPath: string) { // Remove prebuilds for other platforms (HID-win32-*, linux-x64-*, etc.) if (REMOVE_PREBUILD_PREFIXES.some(p => entry.name.startsWith(p)) || REMOVE_HID_PREFIXES.some(p => entry.name.startsWith(p))) { - try { - const result = Bun.spawnSync(['du', '-sk', fullPath]) - nativePrunedSize += parseInt(result.stdout.toString().split('\t')[0] || '0', 10) * 1024 - } catch {} + nativePrunedSize += directorySizeBytes(fullPath) rmSync(fullPath, { recursive: true }) continue } @@ -550,7 +570,12 @@ function stripDuplicateNestedNodeModules(dirPath: string) { const nestedVer = getPackageVersion(scopedPath) const topVer = getPackageVersion(join(nmDest, scopedName)) if (nestedVer && topVer && nestedVer === topVer) { - rmSync(scopedPath, { recursive: true }) + // On Windows, removing very deep duplicate node_modules can + // partially delete packages and leave broken shadow dirs + // (for example uint8arrays without cjs/src). Keep them. + if (process.platform !== 'win32') { + rmSync(scopedPath, { recursive: true, force: true }) + } } else if (nestedVer && topVer && nestedVer !== topVer) { console.log(` Keeping nested: ${scopedName}@${nestedVer} (top-level: ${topVer})`) } @@ -563,7 +588,11 @@ function stripDuplicateNestedNodeModules(dirPath: string) { const nestedVer = getPackageVersion(nestedPkgPath) const topVer = getPackageVersion(join(nmDest, pkg.name)) if (nestedVer && topVer && nestedVer === topVer) { - rmSync(nestedPkgPath, { recursive: true }) + // See scoped-package case above: partial deletion on Windows + // is worse than a slightly larger bundle. + if (process.platform !== 'win32') { + rmSync(nestedPkgPath, { recursive: true, force: true }) + } } else if (nestedVer && topVer && nestedVer !== topVer) { console.log(` Keeping nested: ${pkg.name}@${nestedVer} (top-level: ${topVer})`) } @@ -573,9 +602,15 @@ function stripDuplicateNestedNodeModules(dirPath: string) { try { if (readdirSync(fullPath).length === 0) rmSync(fullPath, { recursive: true }) } catch {} - } catch { - // If we can't read it, remove it - rmSync(fullPath, { recursive: true }) + } catch (e) { + // On Windows, very deep nested node_modules paths can fail to read + // even though Bun still needs files inside them at runtime. + if (process.platform === 'win32') { + console.warn(` WARN: Preserving unreadable nested node_modules on Windows: ${fullPath}: ${e}`) + } else { + // If we can't read it elsewhere, remove it. + rmSync(fullPath, { recursive: true }) + } } } else { stripDuplicateNestedNodeModules(fullPath) @@ -590,11 +625,10 @@ for (const dir of STRIP_DIRS) { const target = join(nmDest, dir) if (existsSync(target)) { try { - const result = Bun.spawnSync(['du', '-sk', target]) - const kb = parseInt(result.stdout.toString().split('\t')[0] || '0', 10) + const bytes = directorySizeBytes(target) rmSync(target, { recursive: true }) - strippedSize += kb * 1024 - console.log(` Stripped: ${dir} (${(kb / 1024).toFixed(1)}MB)`) + strippedSize += bytes + console.log(` Stripped: ${dir} (${(bytes / 1024 / 1024).toFixed(1)}MB)`) } catch {} } } @@ -690,5 +724,4 @@ function removeDanglingSymlinks(dirPath: string) { removeDanglingSymlinks(nmDest) // Report final size -const { stdout } = Bun.spawnSync(['du', '-sh', nmDest]) -console.log(`[collect-externals] Final size: ${stdout.toString().trim().split('\t')[0]}`) +console.log(`[collect-externals] Final size: ${formatBytes(directorySizeBytes(nmDest))}`) diff --git a/projects/keepkey-vault/scripts/patch-electrobun.sh b/projects/keepkey-vault/scripts/patch-electrobun.sh index 366e34f3..4fb1c8bf 100755 --- a/projects/keepkey-vault/scripts/patch-electrobun.sh +++ b/projects/keepkey-vault/scripts/patch-electrobun.sh @@ -1,41 +1,46 @@ #!/bin/bash # Patch Electrobun's build to: # 1. Use quiet zip mode + larger buffer (prevents ENOBUFS) -# 2. Add NSAppTransportSecurity to Info.plist (allows WKWebView iframe → http://localhost) +# 2. Add NSCameraUsageDescription to Info.plist (allows QR scanning permission) EBUN_CLI="node_modules/electrobun/src/cli/index.ts" +sed_in_place() { + local expr="$1" + local file="$2" + sed -i.bak "$expr" "$file" && rm -f "$file.bak" +} + if [ -f "$EBUN_CLI" ]; then - # Check if already patched (idempotent) if grep -q 'zip -y -r -q -9' "$EBUN_CLI"; then - echo "[patch-electrobun] Already patched, skipping" - exit 0 - fi - # Add -q flag to zip commands and increase maxBuffer - if grep -q '`zip -y -r -9' "$EBUN_CLI"; then - sed -i '' 's/`zip -y -r -9/`zip -y -r -q -9/g' "$EBUN_CLI" + echo "[patch-electrobun] zip quiet mode already patched" + elif grep -q '`zip -y -r -9' "$EBUN_CLI"; then + sed_in_place 's/`zip -y -r -9/`zip -y -r -q -9/g' "$EBUN_CLI" echo "[patch-electrobun] Patched zip quiet mode" else echo "[patch-electrobun] WARNING: zip pattern not found in $EBUN_CLI — Electrobun may have changed" fi - if grep -q 'cwd: dirname(appOrDmgPath),$' "$EBUN_CLI"; then - sed -i '' 's/cwd: dirname(appOrDmgPath),$/cwd: dirname(appOrDmgPath), maxBuffer: 50 * 1024 * 1024,/g' "$EBUN_CLI" + + if grep -q 'maxBuffer: 50 \* 1024 \* 1024' "$EBUN_CLI"; then + echo "[patch-electrobun] maxBuffer already patched" + elif grep -q 'cwd: dirname(appOrDmgPath),$' "$EBUN_CLI"; then + sed_in_place 's/cwd: dirname(appOrDmgPath),$/cwd: dirname(appOrDmgPath), maxBuffer: 50 * 1024 * 1024,/g' "$EBUN_CLI" echo "[patch-electrobun] Patched maxBuffer" else echo "[patch-electrobun] WARNING: maxBuffer pattern not found in $EBUN_CLI — Electrobun may have changed" fi -else - echo "[patch-electrobun] $EBUN_CLI not found, skipping (expected during CI or fresh install)" -fi -# 3. Add NSCameraUsageDescription to Info.plist (getUserMedia needs it on macOS) -if [ -f "$EBUN_CLI" ]; then + # Add NSCameraUsageDescription to Info.plist (getUserMedia needs it on macOS). + # Keep this independent from the zip/maxBuffer patches so reused node_modules + # still get the camera permission string after those patches are already present. if grep -q 'NSCameraUsageDescription' "$EBUN_CLI"; then echo "[patch-electrobun] NSCameraUsageDescription already patched" elif grep -q 'NSAppTransportSecurity' "$EBUN_CLI"; then - sed -i '' 's||NSCameraUsageDescription\n\tKeepKey Vault uses the camera to scan QR codes for wallet addresses.\n|' "$EBUN_CLI" + sed_in_place 's||NSCameraUsageDescription\n\tKeepKey Vault uses the camera to scan QR codes for wallet addresses.\n|' "$EBUN_CLI" echo "[patch-electrobun] Patched NSCameraUsageDescription" else echo "[patch-electrobun] WARNING: Info.plist pattern not found — camera permission may not work" fi +else + echo "[patch-electrobun] $EBUN_CLI not found, skipping (expected during CI or fresh install)" fi # Patch electrobun CLI bootstrap to use --force-local with tar on Windows only. diff --git a/projects/keepkey-vault/src/bun/activity-history.ts b/projects/keepkey-vault/src/bun/activity-history.ts new file mode 100644 index 00000000..c706e8b4 --- /dev/null +++ b/projects/keepkey-vault/src/bun/activity-history.ts @@ -0,0 +1,321 @@ +import { withTimeout } from './engine-controller' +import { getPioneer } from './pioneer' +import { apiLogScanTxidExists, insertApiLog, updateApiLogTxMeta } from './db' +import { BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported, type ChainDef } from '../shared/chains' +import type { ActivityType } from '../shared/types' + +const PIONEER_TIMEOUT_MS = 60_000 + +export type ActivityHistoryScope = { + deviceId: string + walletId: string +} + +export type ActivityHistoryRebuildOptions = { + chainId?: string + chainIds?: string[] + includeHidden?: boolean + dryRun?: boolean + accountIndex?: number +} + +type HistoryQuery = { + caip: string + pubkey: string + label: string + path?: string + scriptType?: string +} + +export type ActivityHistoryChainResult = { + chainId: string + symbol: string + caip: string + queries: Array<{ + label: string + pubkeyPreview: string + path?: string + scriptType?: string + txs: number + }> + txs: number + inserted: number + updated: number + skippedNoTxid: number + skippedDuplicate: number + error?: string +} + +export type ActivityHistoryRebuildResult = { + scope: ActivityHistoryScope & { seedAddress?: string } + dryRun: boolean + scannedAt: number + chains: ActivityHistoryChainResult[] + totals: { + chains: number + queries: number + txs: number + inserted: number + updated: number + skippedNoTxid: number + skippedDuplicate: number + failedChains: number + } +} + +function previewKey(value: string): string { + if (value.length <= 18) return value + return `${value.slice(0, 10)}...${value.slice(-6)}` +} + +function addressNListToBIP32(addressNList: number[]): string { + return 'm/' + addressNList.map(n => n >= 0x80000000 ? `${n - 0x80000000}'` : String(n)).join('/') +} + +function responseAddress(result: any): string { + return (typeof result === 'string' ? result : result?.address || '').trim() +} + +function normalizeTimestamp(tx: any): number { + const raw = tx.timestamp ?? tx.blockTime ?? tx.time + const n = Number(raw) + if (!Number.isFinite(n) || n <= 0) return Date.now() + return n > 1_000_000_000_000 ? n : n * 1000 +} + +function normalizeActivityType(tx: any): ActivityType { + const direction = String(tx.direction || '').toLowerCase() + if (/(send|sent|out|outgoing|debit|withdraw)/.test(direction)) return 'send' + if (/(receive|received|in|incoming|credit|deposit)/.test(direction)) return 'receive' + const value = Number(tx.value) + return Number.isFinite(value) && value < 0 ? 'send' : 'receive' +} + +function normalizeMeta(tx: any, activityType = normalizeActivityType(tx)) { + return { + confirmations: typeof tx.confirmations === 'number' ? tx.confirmations : 0, + blockHeight: tx.blockHeight || tx.block_height || tx.height || 0, + value: tx.value != null ? String(tx.value) : undefined, + fee: tx.fee != null ? String(tx.fee) : undefined, + direction: activityType === 'send' ? 'sent' : 'received', + } +} + +function unwrapHistoryTransactions(resp: any): any[] { + const data = resp?.data || resp + const histories = data?.histories || data?.data?.histories || [] + return histories.flatMap((h: any) => Array.isArray(h?.transactions) ? h.transactions : []) +} + +async function deriveHistoryQueries(wallet: any, chain: ChainDef, accountIndex: number): Promise { + if (chain.chainFamily === 'utxo') { + const paths = chain.id === 'bitcoin' + ? BTC_SCRIPT_TYPES.map(st => ({ + addressNList: btcAccountPath(st.purpose, accountIndex), + scriptType: st.scriptType, + label: `account-${accountIndex}-${st.scriptType}`, + })) + : [{ + addressNList: chain.defaultPath.slice(0, 3), + scriptType: chain.scriptType || 'p2pkh', + label: `account-${accountIndex}-${chain.scriptType || 'p2pkh'}`, + }] + + const results = await wallet.getPublicKeys(paths.map(p => ({ + addressNList: p.addressNList, + curve: 'secp256k1', + coin: chain.coin, + scriptType: p.scriptType, + showDisplay: false, + }))) + + return paths + .map((p, i) => ({ + caip: chain.caip, + pubkey: String(results?.[i]?.xpub || ''), + label: p.label, + path: addressNListToBIP32(p.addressNList), + scriptType: p.scriptType, + })) + .filter(q => q.pubkey) + } + + if (chain.chainFamily === 'evm') { + const result = await wallet.ethGetAddress({ + addressNList: chain.defaultPath, + showDisplay: false, + coin: 'Ethereum', + }) + const pubkey = responseAddress(result) + return pubkey ? [{ + caip: chain.caip, + pubkey, + label: 'default', + path: addressNListToBIP32(chain.defaultPath), + }] : [] + } + + const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod + if (typeof wallet[method] !== 'function') { + throw new Error(`Wallet method unavailable: ${method}`) + } + + const params: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } + if (chain.scriptType) params.scriptType = chain.scriptType + if (chain.chainFamily === 'ton') params.bounceable = false + + const result = await wallet[method](params) + const pubkey = responseAddress(result) + return pubkey ? [{ + caip: chain.caip, + pubkey, + label: 'default', + path: addressNListToBIP32(chain.defaultPath), + scriptType: chain.scriptType, + }] : [] +} + +function selectChains( + chains: ChainDef[], + firmwareVersion: string | undefined, + options: ActivityHistoryRebuildOptions, +): ChainDef[] { + const requested = new Set([...(options.chainIds || []), ...(options.chainId ? [options.chainId] : [])]) + return chains + .filter(chain => requested.size === 0 || requested.has(chain.id) || requested.has(chain.symbol)) + .filter(chain => options.includeHidden || !chain.hidden) + .filter(chain => chain.chainFamily !== 'zcash-shielded') + .filter(chain => isChainSupported(chain, firmwareVersion)) +} + +export async function rebuildActivityHistory(params: { + wallet: any + scope: ActivityHistoryScope + chains: ChainDef[] + firmwareVersion?: string + options?: ActivityHistoryRebuildOptions +}): Promise { + const options = params.options || {} + const dryRun = !!options.dryRun + const accountIndex = Math.max(0, Number.isInteger(options.accountIndex) ? options.accountIndex! : 0) + const selectedChains = selectChains(params.chains, params.firmwareVersion, options) + const pioneer = await getPioneer() + const result: ActivityHistoryRebuildResult = { + scope: { + ...params.scope, + seedAddress: params.scope.walletId.includes(':') ? params.scope.walletId.split(':').pop() : undefined, + }, + dryRun, + scannedAt: Date.now(), + chains: [], + totals: { + chains: selectedChains.length, + queries: 0, + txs: 0, + inserted: 0, + updated: 0, + skippedNoTxid: 0, + skippedDuplicate: 0, + failedChains: 0, + }, + } + + for (const chain of selectedChains) { + const chainResult: ActivityHistoryChainResult = { + chainId: chain.id, + symbol: chain.symbol, + caip: chain.caip, + queries: [], + txs: 0, + inserted: 0, + updated: 0, + skippedNoTxid: 0, + skippedDuplicate: 0, + } + const seenTxids = new Set() + + try { + const queries = await deriveHistoryQueries(params.wallet, chain, accountIndex) + result.totals.queries += queries.length + + for (const query of queries) { + const resp = await withTimeout( + pioneer.GetTransactionHistory({ queries: [{ pubkey: query.pubkey, caip: query.caip }] }), + PIONEER_TIMEOUT_MS, + `GetTransactionHistory(${chain.symbol}:${query.label})`, + ) + const txs = unwrapHistoryTransactions(resp) + chainResult.queries.push({ + label: query.label, + pubkeyPreview: previewKey(query.pubkey), + path: query.path, + scriptType: query.scriptType, + txs: txs.length, + }) + chainResult.txs += txs.length + + for (const tx of txs) { + const txid = String(tx.txid || tx.hash || tx.txHash || '').trim() + if (!txid) { + chainResult.skippedNoTxid++ + continue + } + if (seenTxids.has(txid)) { + chainResult.skippedDuplicate++ + continue + } + seenTxids.add(txid) + + const activityType = normalizeActivityType(tx) + const timestamp = normalizeTimestamp(tx) + const route = `history/${chain.id}` + const meta = { + ...normalizeMeta(tx, activityType), + chainId: chain.id, + chainSymbol: chain.symbol, + networkId: chain.networkId, + } + const exists = apiLogScanTxidExists(txid, params.scope.deviceId, params.scope.walletId) + if (!dryRun) { + if (exists) { + updateApiLogTxMeta(txid, meta, params.scope.deviceId, params.scope.walletId, { + activityType, + chain: chain.symbol, + route, + timestamp, + }) + } else { + insertApiLog({ + ...params.scope, + method: 'SCAN', + route, + timestamp, + durationMs: 0, + status: 200, + appName: 'vault', + txid, + chain: chain.symbol, + activityType, + responseBody: meta, + }) + } + } + if (exists) chainResult.updated++ + else chainResult.inserted++ + } + } + } catch (err: any) { + chainResult.error = err?.message || String(err) + result.totals.failedChains++ + } + + result.totals.txs += chainResult.txs + result.totals.inserted += chainResult.inserted + result.totals.updated += chainResult.updated + result.totals.skippedNoTxid += chainResult.skippedNoTxid + result.totals.skippedDuplicate += chainResult.skippedDuplicate + result.chains.push(chainResult) + } + + return result +} diff --git a/projects/keepkey-vault/src/bun/auth.ts b/projects/keepkey-vault/src/bun/auth.ts index 4380e8b0..55c663eb 100644 --- a/projects/keepkey-vault/src/bun/auth.ts +++ b/projects/keepkey-vault/src/bun/auth.ts @@ -1,12 +1,17 @@ import { getStoredPairings, storePairing, removePairing, clearPairings } from './db' import type { PairedAppInfo } from '../shared/types' -/** HTTP-aware error with status code — caught by rest-api error handler */ +/** HTTP-aware error with status code — caught by rest-api error handler. + * `details` is an optional structured payload returned alongside the error + * message (e.g. `{ boc, txid }` so a caller can retry broadcast without + * re-signing). */ export class HttpError extends Error { status: number - constructor(status: number, message: string) { + details?: unknown + constructor(status: number, message: string, details?: unknown) { super(message) this.status = status + if (details !== undefined) this.details = details } } diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 643385f5..a5d11e06 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -77,9 +77,17 @@ export function initDb() { name TEXT NOT NULL, decimals INTEGER NOT NULL DEFAULT 18, network_id TEXT NOT NULL, + icon_url TEXT, PRIMARY KEY (chain_id, contract_address) ) `) + // Migration for existing DBs created before icon_url. SQLite throws on + // duplicate-column ADD; swallow that and propagate any other failure. + try { + db.exec(`ALTER TABLE custom_tokens ADD COLUMN icon_url TEXT`) + } catch (e: any) { + if (!String(e?.message || e).match(/duplicate column/i)) throw e + } db.exec(` CREATE TABLE IF NOT EXISTS custom_chains ( @@ -121,6 +129,8 @@ export function initDb() { db.exec(` CREATE TABLE IF NOT EXISTS api_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT, + wallet_id TEXT, method TEXT NOT NULL, route TEXT NOT NULL, timestamp INTEGER NOT NULL, @@ -185,6 +195,8 @@ export function initDb() { db.exec(` CREATE TABLE IF NOT EXISTS swap_history ( id TEXT PRIMARY KEY, + device_id TEXT, + wallet_id TEXT, txid TEXT NOT NULL, from_asset TEXT NOT NULL, to_asset TEXT NOT NULL, @@ -192,6 +204,8 @@ export function initDb() { to_symbol TEXT NOT NULL, from_chain_id TEXT NOT NULL, to_chain_id TEXT NOT NULL, + from_caip TEXT, + to_caip TEXT, from_amount TEXT NOT NULL, quoted_output TEXT NOT NULL, minimum_output TEXT NOT NULL DEFAULT '0', @@ -200,6 +214,7 @@ export function initDb() { fee_bps INTEGER NOT NULL DEFAULT 0, fee_outbound TEXT NOT NULL DEFAULT '0', integration TEXT NOT NULL DEFAULT 'thorchain', + swapper TEXT, memo TEXT NOT NULL DEFAULT '', inbound_address TEXT NOT NULL DEFAULT '', router TEXT, @@ -248,15 +263,55 @@ export function initDb() { ) } + // Per-emulator-wallet metadata (keyed by flash name, stable across re-imports). + // Kept separate from device_snapshot so ephemeral emu identities never + // contaminate the registered-device list and snapshots stay privacy-safe. + db.exec(` + CREATE TABLE IF NOT EXISTS emulator_wallet ( + name TEXT PRIMARY KEY, + label TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL DEFAULT '', + firmware_version TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL + ) + `) + // 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 */ } } // Activity tracking columns on api_log (sign/broadcast ops) - for (const col of ['txid TEXT', 'chain TEXT', 'activity_type TEXT']) { + for (const col of ['txid TEXT', 'chain TEXT', 'activity_type TEXT', 'device_id TEXT', 'wallet_id TEXT']) { try { db.exec(`ALTER TABLE api_log ADD COLUMN ${col}`) } catch { /* already exists */ } } + try { db.exec(`ALTER TABLE swap_history ADD COLUMN device_id TEXT`) } catch { /* already exists */ } + try { db.exec(`ALTER TABLE swap_history ADD COLUMN wallet_id TEXT`) } catch { /* already exists */ } + // Underlying protocol when integration is an aggregator (e.g. Relay, 0x via ShapeShift) + try { db.exec(`ALTER TABLE swap_history ADD COLUMN swapper TEXT`) } catch { /* already exists */ } + // CAIPs for both sides — needed so the SwapDialog resume path can render + // asset logos without a Pioneer round-trip. + for (const col of ['from_caip TEXT', 'to_caip TEXT']) { + try { db.exec(`ALTER TABLE swap_history ADD COLUMN ${col}`) } catch { /* already exists */ } + } + // Relay's bytes32 request id — drives the "Relay Track" external link. + // Filled at trackSwap time via on-chain calldata, or lazily backfilled + // by refreshSwap via api.relay.link for legacy rows. + try { db.exec(`ALTER TABLE swap_history ADD COLUMN relay_request_id TEXT`) } catch { /* already exists */ } + // Outbound chain truth from Maya midgard classifier — refunds outbound on + // source chain, not destination. Without this column, history+activity + // panels still resolve explorer URLs against toChainId and a refunded + // ETH→ZEC opens a Zcash explorer for an ETH refund tx. + for (const col of ['outbound_chain_id TEXT', 'refund_reason TEXT']) { + try { db.exec(`ALTER TABLE swap_history ADD COLUMN ${col}`) } catch { /* already exists */ } + } try { db.exec(`CREATE INDEX IF NOT EXISTS idx_api_log_activity ON api_log(activity_type)`) } catch { /* already exists */ } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_api_log_device_ts ON api_log(device_id, timestamp DESC)`) } catch { /* already exists */ } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_api_log_wallet_ts ON api_log(wallet_id, timestamp DESC)`) } catch { /* already exists */ } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_device_created ON swap_history(device_id, created_at DESC)`) } catch { /* already exists */ } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_device_txid ON swap_history(device_id, txid)`) } catch { /* already exists */ } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_wallet_created ON swap_history(wallet_id, created_at DESC)`) } catch { /* already exists */ } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_swap_history_wallet_txid ON swap_history(wallet_id, txid)`) } catch { /* already exists */ } console.log(`[db] SQLite cache ready at ${dbPath}`) } catch (e: any) { @@ -320,6 +375,7 @@ export function getCachedBalances(deviceId: string): { balances: ChainBalance[]; balance: r.balance, balanceUsd: r.balance_usd, address: r.address, + updatedAt: r.updated_at, } if (r.tokens_json) { try { entry.tokens = JSON.parse(r.tokens_json) } catch { /* corrupt JSON, skip tokens */ } @@ -340,9 +396,20 @@ export function setCachedBalances(deviceId: string, balances: ChainBalance[]) { try { if (!db) return const now = Date.now() + // No-walk-backwards upsert: only overwrite a cached non-zero balance if the new value is + // also non-zero. This prevents Pioneer cold-cache or transient 0 responses from wiping + // a balance the user has actually seen. The updated_at timestamp is only advanced when + // the balance is genuinely updated, so the UI accurately shows "from X ago". const stmt = db.prepare( - `INSERT OR REPLACE INTO balances (device_id, chain_id, symbol, balance, balance_usd, address, tokens_json, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO balances (device_id, chain_id, symbol, balance, balance_usd, address, tokens_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id, chain_id) DO UPDATE SET + symbol = excluded.symbol, + address = CASE WHEN excluded.address != '' THEN excluded.address ELSE address END, + balance = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance ELSE balance END, + balance_usd= CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance_usd ELSE balance_usd END, + tokens_json= CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.tokens_json ELSE tokens_json END, + updated_at = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.updated_at ELSE updated_at END` ) const tx = db.transaction(() => { for (const b of balances) { @@ -356,14 +423,21 @@ export function setCachedBalances(deviceId: string, balances: ChainBalance[]) { } } -/** Update a single chain's cached balance (upsert). */ +/** Update a single chain's cached balance. Never downgrades a non-zero cached value to zero. */ export function updateCachedBalance(deviceId: string, balance: ChainBalance) { try { if (!db) return const tokensJson = balance.tokens && balance.tokens.length > 0 ? JSON.stringify(balance.tokens) : null db.run( - `INSERT OR REPLACE INTO balances (device_id, chain_id, symbol, balance, balance_usd, address, tokens_json, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO balances (device_id, chain_id, symbol, balance, balance_usd, address, tokens_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id, chain_id) DO UPDATE SET + symbol = excluded.symbol, + address = CASE WHEN excluded.address != '' THEN excluded.address ELSE address END, + balance = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance ELSE balance END, + balance_usd= CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance_usd ELSE balance_usd END, + tokens_json= CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.tokens_json ELSE tokens_json END, + updated_at = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.updated_at ELSE updated_at END`, [deviceId, balance.chainId, balance.symbol, balance.balance, balance.balanceUsd, balance.address, tokensJson, Date.now()] ) } catch (e: any) { @@ -389,10 +463,18 @@ export function clearBalances(deviceId?: string) { export function getCustomTokens(): CustomToken[] { try { if (!db) return [] - const rows = db.query('SELECT chain_id, contract_address, symbol, name, decimals, network_id FROM custom_tokens').all() as Array<{ - chain_id: string; contract_address: string; symbol: string; name: string; decimals: number; network_id: string + const rows = db.query('SELECT chain_id, contract_address, symbol, name, decimals, network_id, icon_url FROM custom_tokens').all() as Array<{ + chain_id: string; contract_address: string; symbol: string; name: string; decimals: number; network_id: string; icon_url: string | null }> - return rows.map(r => ({ chainId: r.chain_id, contractAddress: r.contract_address, symbol: r.symbol, name: r.name, decimals: r.decimals, networkId: r.network_id })) + return rows.map(r => ({ + chainId: r.chain_id, + contractAddress: r.contract_address, + symbol: r.symbol, + name: r.name, + decimals: r.decimals, + networkId: r.network_id, + iconUrl: r.icon_url || undefined, + })) } catch (e: any) { console.warn('[db] getCustomTokens failed:', e.message) return [] @@ -403,8 +485,8 @@ export function addCustomToken(token: CustomToken) { try { if (!db) return db.run( - `INSERT OR REPLACE INTO custom_tokens (chain_id, contract_address, symbol, name, decimals, network_id) VALUES (?, ?, ?, ?, ?, ?)`, - [token.chainId, token.contractAddress, token.symbol, token.name, token.decimals, token.networkId] + `INSERT OR REPLACE INTO custom_tokens (chain_id, contract_address, symbol, name, decimals, network_id, icon_url) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [token.chainId, token.contractAddress, token.symbol, token.name, token.decimals, token.networkId, token.iconUrl ?? null] ) } catch (e: any) { console.warn('[db] addCustomToken failed:', e.message) @@ -420,6 +502,18 @@ export function removeCustomToken(chainId: string, contractAddress: string) { } } +export function setCustomTokenIcon(chainId: string, contractAddress: string, iconUrl: string): boolean { + try { + if (!db) return false + const res = db.run('UPDATE custom_tokens SET icon_url = ? WHERE chain_id = ? AND contract_address = ?', [iconUrl, chainId, contractAddress]) + // bun:sqlite returns { changes } on .run(); guard for 0 changes (token row missing) + return Boolean((res as any)?.changes) + } catch (e: any) { + console.warn('[db] setCustomTokenIcon failed:', e.message) + return false + } +} + // ── Custom Chains ──────────────────────────────────────────────────── export function getCustomChains(): CustomChain[] { @@ -638,15 +732,41 @@ export function clearPairings() { // ── API Audit Log ────────────────────────────────────────────────────── const MAX_API_LOG_ROWS = 5000 +const REDACTED_API_LOG_KEYS = new Set(['apiKey']) + +function parseApiLogJson(raw: string | null): any { + if (!raw) return undefined + try { + return redactApiLogValue(JSON.parse(raw)) + } catch { + return undefined + } +} + +function redactApiLogValue(value: any, depth = 0): any { + if (!value || typeof value !== 'object' || depth > 8) return value + if (Array.isArray(value)) return value.map(v => redactApiLogValue(v, depth + 1)) + const out: any = {} + for (const [key, child] of Object.entries(value)) { + if (REDACTED_API_LOG_KEYS.has(key)) { + out[key] = '[trimmed]' + } else { + out[key] = redactApiLogValue(child, depth + 1) + } + } + return out +} /** Insert an API log entry and prune old rows beyond MAX_API_LOG_ROWS */ export function insertApiLog(entry: ApiLogEntry) { try { if (!db) return db.run( - `INSERT INTO api_log (method, route, timestamp, duration_ms, status, app_name, image_url, request_body, response_body, txid, chain, activity_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO api_log (device_id, wallet_id, method, route, timestamp, duration_ms, status, app_name, image_url, request_body, response_body, txid, chain, activity_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ + entry.deviceId || null, + entry.walletId || null, entry.method, entry.route, entry.timestamp, @@ -661,9 +781,21 @@ export function insertApiLog(entry: ApiLogEntry) { entry.activityType || null, ] ) - // Periodic prune (every ~100 inserts, check if over limit) + // Periodic prune (every ~100 inserts). Keep rebuilt chain-history rows; + // cap only generic API audit noise so a full wallet rebuild cannot be + // hollowed out by /docs, balance, or address polling entries. if (Math.random() < 0.01) { - db.run(`DELETE FROM api_log WHERE id NOT IN (SELECT id FROM api_log ORDER BY timestamp DESC LIMIT ?)`, [MAX_API_LOG_ROWS]) + db.run( + `DELETE FROM api_log + WHERE method <> 'SCAN' + AND id NOT IN ( + SELECT id FROM api_log + WHERE method <> 'SCAN' + ORDER BY timestamp DESC + LIMIT ? + )`, + [MAX_API_LOG_ROWS], + ) } } catch (e: any) { console.warn('[db] insertApiLog failed:', e.message) @@ -671,18 +803,23 @@ export function insertApiLog(entry: ApiLogEntry) { } /** Get recent API log entries (newest first) */ -export function getApiLogs(limit = 200, offset = 0): ApiLogEntry[] { +export function getApiLogs(limit = 200, offset = 0, deviceId?: string, walletId?: string): ApiLogEntry[] { try { if (!db) return [] + const whereSql = walletId ? 'WHERE wallet_id = ?' : deviceId ? 'WHERE device_id = ?' : '' + const scopeArgs = walletId ? [walletId] : deviceId ? [deviceId] : [] + const args = [...scopeArgs, limit, offset] const rows = db.query( - 'SELECT id, method, route, timestamp, duration_ms, status, app_name, image_url, request_body, response_body FROM api_log ORDER BY timestamp DESC LIMIT ? OFFSET ?' - ).all(limit, offset) as Array<{ - id: number; method: string; route: string; timestamp: number; duration_ms: number; + `SELECT id, device_id, wallet_id, method, route, timestamp, duration_ms, status, app_name, image_url, request_body, response_body FROM api_log ${whereSql} ORDER BY timestamp DESC LIMIT ? OFFSET ?` + ).all(...args) as Array<{ + id: number; device_id: string | null; wallet_id: string | null; method: string; route: string; timestamp: number; duration_ms: number; status: number; app_name: string; image_url: string | null; request_body: string | null; response_body: string | null }> return rows.map(r => ({ id: r.id, + deviceId: r.device_id || undefined, + walletId: r.wallet_id || undefined, method: r.method, route: r.route, timestamp: r.timestamp, @@ -690,8 +827,8 @@ export function getApiLogs(limit = 200, offset = 0): ApiLogEntry[] { status: r.status, appName: r.app_name, imageUrl: r.image_url || undefined, - requestBody: r.request_body ? JSON.parse(r.request_body) : undefined, - responseBody: r.response_body ? JSON.parse(r.response_body) : undefined, + requestBody: parseApiLogJson(r.request_body), + responseBody: parseApiLogJson(r.response_body), })) } catch (e: any) { console.warn('[db] getApiLogs failed:', e.message) @@ -700,34 +837,149 @@ export function getApiLogs(limit = 200, offset = 0): ApiLogEntry[] { } /** Clear all API logs */ -export function clearApiLogs() { +export function clearApiLogs(deviceId?: string, walletId?: string) { try { if (!db) return - db.run('DELETE FROM api_log') + if (walletId) db.run('DELETE FROM api_log WHERE wallet_id = ?', [walletId]) + else if (deviceId) db.run('DELETE FROM api_log WHERE device_id = ?', [deviceId]) + else db.run('DELETE FROM api_log') } catch (e: any) { console.warn('[db] clearApiLogs failed:', e.message) } } +export interface ApiLogFilter { + deviceId?: string + walletId?: string + route?: string + activityType?: string + txid?: string + chain?: string + since?: number + until?: number + limit?: number + offset?: number +} + +/** Filtered query over api_log (newest first). Returns full request/response bodies. */ +export function findApiLogs(filter: ApiLogFilter = {}): ApiLogEntry[] { + try { + if (!db) return [] + const where: string[] = [] + const args: any[] = [] + if (filter.walletId) { where.push('wallet_id = ?'); args.push(filter.walletId) } + if (filter.deviceId) { where.push('device_id = ?'); args.push(filter.deviceId) } + if (filter.route) { where.push('route = ?'); args.push(filter.route) } + if (filter.activityType) { where.push('activity_type = ?'); args.push(filter.activityType) } + if (filter.txid) { where.push('txid = ?'); args.push(filter.txid) } + if (filter.chain) { where.push('chain = ?'); args.push(filter.chain) } + if (filter.since) { where.push('timestamp >= ?'); args.push(filter.since) } + if (filter.until) { where.push('timestamp <= ?'); args.push(filter.until) } + const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '' + const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500) + const offset = Math.max(filter.offset ?? 0, 0) + const rows = db.query( + `SELECT id, method, route, timestamp, duration_ms, status, app_name, image_url, + request_body, response_body, txid, chain, activity_type, device_id, wallet_id + FROM api_log ${whereSql} + ORDER BY timestamp DESC LIMIT ? OFFSET ?` + ).all(...args, limit, offset) as Array + return rows.map(r => ({ + id: r.id, + deviceId: r.device_id || undefined, + walletId: r.wallet_id || undefined, + method: r.method, + route: r.route, + timestamp: r.timestamp, + durationMs: r.duration_ms, + status: r.status, + appName: r.app_name, + imageUrl: r.image_url || undefined, + requestBody: parseApiLogJson(r.request_body), + responseBody: parseApiLogJson(r.response_body), + txid: r.txid || undefined, + chain: r.chain || undefined, + activityType: r.activity_type || undefined, + })) + } catch (e: any) { + console.warn('[db] findApiLogs failed:', e.message) + return [] + } +} + +/** Single api_log entry by id with full bodies. */ +export function getApiLogById(id: number, deviceId?: string, walletId?: string): ApiLogEntry | null { + try { + if (!db) return null + const whereSql = walletId ? 'id = ? AND wallet_id = ?' : deviceId ? 'id = ? AND device_id = ?' : 'id = ?' + const args = walletId ? [id, walletId] : deviceId ? [id, deviceId] : [id] + const r: any = db.query( + `SELECT id, method, route, timestamp, duration_ms, status, app_name, image_url, + request_body, response_body, txid, chain, activity_type, device_id, wallet_id + FROM api_log WHERE ${whereSql} LIMIT 1` + ).get(...args) + if (!r) return null + return { + id: r.id, + deviceId: r.device_id || undefined, + walletId: r.wallet_id || undefined, + method: r.method, + route: r.route, + timestamp: r.timestamp, + durationMs: r.duration_ms, + status: r.status, + appName: r.app_name, + imageUrl: r.image_url || undefined, + requestBody: parseApiLogJson(r.request_body), + responseBody: parseApiLogJson(r.response_body), + txid: r.txid || undefined, + chain: r.chain || undefined, + activityType: r.activity_type || undefined, + } + } catch (e: any) { + console.warn('[db] getApiLogById failed:', e.message) + return null + } +} + // ── Recent Activity (unified from api_log + swap_history) ───────────── import type { RecentActivity, ActivityType, ActivitySource } from '../shared/types' -/** Check if a txid already exists in api_log */ -export function apiLogTxidExists(txid: string): boolean { +/** Check if a rebuilt scan row already exists in api_log. */ +export function apiLogScanTxidExists(txid: string, deviceId?: string, walletId?: string): boolean { try { if (!db) return false - const row = db.query('SELECT 1 FROM api_log WHERE txid = ? LIMIT 1').get(txid) + const row = walletId + ? db.query("SELECT 1 FROM api_log WHERE txid = ? AND wallet_id = ? AND method = 'SCAN' LIMIT 1").get(txid, walletId) + : deviceId + ? db.query("SELECT 1 FROM api_log WHERE txid = ? AND device_id = ? AND method = 'SCAN' LIMIT 1").get(txid, deviceId) + : db.query("SELECT 1 FROM api_log WHERE txid = ? AND method = 'SCAN' LIMIT 1").get(txid) return !!row } catch { return false } } -/** Update response_body metadata for an existing api_log entry by txid (used to refresh confirmation counts) */ -export function updateApiLogTxMeta(txid: string, meta: Record) { +/** Update metadata for an existing rebuilt scan row. */ +export function updateApiLogTxMeta( + txid: string, + meta: Record, + deviceId?: string, + walletId?: string, + activity?: { activityType?: string; chain?: string; route?: string; timestamp?: number }, +) { try { if (!db) return - db.run('UPDATE api_log SET response_body = ? WHERE txid = ?', [JSON.stringify(meta), txid]) + const setSql: string[] = ['response_body = ?'] + const args: any[] = [JSON.stringify(meta)] + if (activity?.activityType) { setSql.push('activity_type = ?'); args.push(activity.activityType) } + if (activity?.chain) { setSql.push('chain = ?'); args.push(activity.chain) } + if (activity?.route) { setSql.push('route = ?'); args.push(activity.route) } + if (activity?.timestamp) { setSql.push('timestamp = ?'); args.push(activity.timestamp) } + + if (walletId) db.run(`UPDATE api_log SET ${setSql.join(', ')} WHERE txid = ? AND wallet_id = ? AND method = 'SCAN'`, [...args, txid, walletId]) + else if (deviceId) db.run(`UPDATE api_log SET ${setSql.join(', ')} WHERE txid = ? AND device_id = ? AND method = 'SCAN'`, [...args, txid, deviceId]) + else db.run(`UPDATE api_log SET ${setSql.join(', ')} WHERE txid = ? AND method = 'SCAN'`, [...args, txid]) } catch (e: any) { console.warn('[db] updateApiLogTxMeta failed:', e.message) } @@ -736,35 +988,46 @@ export function updateApiLogTxMeta(txid: string, meta: Record) { const VALID_ACTIVITY_TYPES = new Set(['send', 'receive', 'swap', 'sign', 'message', 'approve', 'broadcast']) /** Query api_log entries that have activity_type set + swap_history, merged by timestamp */ -export function getRecentActivityFromLog(limit = 50, chainFilter?: string): RecentActivity[] { +export function getRecentActivityFromLog(limit = 50, chainFilter?: string, deviceId?: string, walletId?: string): RecentActivity[] { try { if (!db) return [] // Build query with optional chain filter - let logSql = `SELECT id, txid, chain, activity_type, app_name, timestamp, route, method, response_body + let logSql = `SELECT id, device_id, wallet_id, txid, chain, activity_type, app_name, timestamp, route, method, response_body FROM api_log WHERE activity_type IS NOT NULL` const logParams: any[] = [] + if (walletId) { + logSql += ` AND wallet_id = ?` + logParams.push(walletId) + } + if (deviceId) { + logSql += ` AND device_id = ?` + logParams.push(deviceId) + } if (chainFilter) { - logSql += ` AND chain = ?` - logParams.push(chainFilter) + logSql += ` AND (chain = ? OR route = ? OR response_body LIKE ?)` + logParams.push(chainFilter, `history/${chainFilter}`, `%"chainId":"${chainFilter}"%`) } logSql += ` ORDER BY timestamp DESC LIMIT ?` logParams.push(limit) const logRows = db.query(logSql).all(...logParams) as Array<{ - id: number; txid: string | null; chain: string | null; activity_type: string; + id: number; device_id: string | null; wallet_id: string | null; txid: string | null; chain: string | null; activity_type: string; app_name: string; timestamp: number; route: string; method: string; response_body: string | null }> - const logActivities: RecentActivity[] = logRows.map(r => { + const rawLogActivities: RecentActivity[] = logRows.map(r => { // Parse tx metadata from response_body (stored by scan) let meta: any = null if (r.response_body) { try { meta = JSON.parse(r.response_body) } catch {} } const isScan = r.method === 'SCAN' return { id: String(r.id), + deviceId: r.device_id || undefined, + walletId: r.wallet_id || undefined, txid: r.txid || undefined, - chain: r.chain || '?', + chain: meta?.chainSymbol || r.chain || '?', + chainId: meta?.chainId ?? undefined, type: (VALID_ACTIVITY_TYPES.has(r.activity_type) ? (r.activity_type === 'broadcast' ? 'send' : r.activity_type) : 'sign') as ActivityType, source: isScan ? 'scan' : (r.method === 'RPC' ? 'app' : 'api') as ActivitySource, appName: r.method === 'RPC' ? undefined : (isScan ? undefined : r.app_name), @@ -778,34 +1041,58 @@ export function getRecentActivityFromLog(limit = 50, chainFilter?: string): Rece }) // Swap history entries (dedupe by txid against logActivities) - let swapSql = `SELECT id, txid, from_symbol, to_symbol, from_chain_id, from_amount, status, created_at + let swapSql = `SELECT id, device_id, wallet_id, txid, from_symbol, to_symbol, from_chain_id, to_chain_id, + from_caip, to_caip, from_amount, quoted_output, received_output, status, created_at FROM swap_history` const swapParams: any[] = [] + const swapWhere: string[] = [] + if (walletId) { + swapWhere.push(`wallet_id = ?`) + swapParams.push(walletId) + } + if (deviceId) { + swapWhere.push(`device_id = ?`) + swapParams.push(deviceId) + } if (chainFilter) { // Match swap by either source or destination chain (e.g. ETH->BTC visible under both ETH and BTC) - swapSql += ` WHERE from_symbol = ? OR to_symbol = ?` - swapParams.push(chainFilter, chainFilter) + swapWhere.push(`(from_symbol = ? OR to_symbol = ? OR from_chain_id = ? OR to_chain_id = ?)`) + swapParams.push(chainFilter, chainFilter, chainFilter, chainFilter) } + if (swapWhere.length) swapSql += ` WHERE ${swapWhere.join(' AND ')}` swapSql += ` ORDER BY created_at DESC LIMIT ?` swapParams.push(limit) const swapRows = db.query(swapSql).all(...swapParams) as Array<{ - id: string; txid: string; from_symbol: string; to_symbol: string; - from_chain_id: string; from_amount: string; status: string; created_at: number + id: string; device_id: string | null; wallet_id: string | null; txid: string; from_symbol: string; to_symbol: string; + from_chain_id: string; to_chain_id: string; + from_caip: string | null; to_caip: string | null; + from_amount: string; quoted_output: string; received_output: string | null; + status: string; created_at: number }> - const logTxids = new Set(logActivities.filter(a => a.txid).map(a => a.txid)) + const swapLogTxids = new Set(rawLogActivities.filter(a => a.type === 'swap' && a.txid).map(a => a.txid)) + const swapRowTxids = new Set(swapRows.filter(r => r.txid).map(r => r.txid)) + const allSwapTxids = new Set([...swapLogTxids, ...swapRowTxids]) + const logActivities = rawLogActivities.filter(a => !(a.txid && a.type !== 'swap' && allSwapTxids.has(a.txid))) const swapActivities: RecentActivity[] = swapRows - .filter(r => !logTxids.has(r.txid)) + .filter(r => !swapLogTxids.has(r.txid)) .map(r => ({ id: r.id, + deviceId: r.device_id || undefined, + walletId: r.wallet_id || undefined, txid: r.txid, chain: r.from_symbol, chainId: r.from_chain_id, type: 'swap' as const, source: 'app' as const, amount: r.from_amount, - asset: `${r.from_symbol}\u2192${r.to_symbol}`, + asset: r.from_symbol, + outAmount: r.received_output || r.quoted_output || undefined, + outAsset: r.to_symbol, + outChainId: r.to_chain_id, + fromCaip: r.from_caip || undefined, + toCaip: r.to_caip || undefined, status: r.status === 'completed' ? 'completed' as const : r.status === 'failed' ? 'failed' as const : r.status === 'refunded' ? 'refunded' as const : 'broadcast' as const, swapStatus: r.status as any, createdAt: r.created_at, @@ -887,18 +1174,86 @@ export function deleteDeviceSnapshot(deviceId: string) { db.run('DELETE FROM cached_pubkeys WHERE device_id = ?', [deviceId]) db.run('DELETE FROM balances WHERE device_id = ?', [deviceId]) db.run('DELETE FROM reports WHERE device_id = ?', [deviceId]) + db.run('DELETE FROM api_log WHERE device_id = ?', [deviceId]) + db.run('DELETE FROM swap_history WHERE device_id = ?', [deviceId]) } catch (e: any) { console.warn('[db] deleteDeviceSnapshot failed:', e.message) } } +// ── Emulator Wallet Metadata ──────────────────────────────────────── + +export interface EmulatorWalletMeta { + name: string + label: string + deviceId: string + firmwareVersion: string + channel: string + updatedAt: number + totalUsd: number +} + +export function saveEmulatorWalletMeta(name: string, label: string, deviceId: string, firmwareVersion: string, channel: string) { + try { + if (!db) return + db.run( + `INSERT OR REPLACE INTO emulator_wallet (name, label, device_id, firmware_version, channel, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + [name, label, deviceId, firmwareVersion, channel, Date.now()] + ) + } catch (e: any) { + console.warn('[db] saveEmulatorWalletMeta failed:', e.message) + } +} + +export function getAllEmulatorWalletMeta(): EmulatorWalletMeta[] { + try { + if (!db) return [] + const rows = db.query(` + SELECT w.name, w.label, w.device_id, w.firmware_version, w.channel, w.updated_at, + COALESCE(SUM(b.balance_usd), 0) AS total_usd + FROM emulator_wallet w + LEFT JOIN balances b ON b.device_id = w.device_id + GROUP BY w.name + `).all() as Array<{ name: string; label: string; device_id: string; firmware_version: string; channel: string; updated_at: number; total_usd: number }> + return rows.map(r => ({ + name: r.name, + label: r.label, + deviceId: r.device_id, + firmwareVersion: r.firmware_version, + channel: r.channel, + updatedAt: r.updated_at, + totalUsd: r.total_usd, + })) + } catch (e: any) { + console.warn('[db] getAllEmulatorWalletMeta failed:', e.message) + return [] + } +} + +export function deleteEmulatorWalletMeta(name: string) { + try { + if (!db) return + db.run('DELETE FROM emulator_wallet WHERE name = ?', [name]) + } catch (e: any) { + console.warn('[db] deleteEmulatorWalletMeta failed:', e.message) + } +} + // ── Cached Pubkeys (watch-only address cache) ─────────────────────── export function saveCachedPubkey(deviceId: string, chainId: string, path: string, xpub: string, address: string, scriptType: string, balance?: string, balanceUsd?: number) { try { if (!db) return db.run( - `INSERT OR REPLACE INTO cached_pubkeys (device_id, chain_id, path, xpub, address, script_type, balance, balance_usd, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO cached_pubkeys (device_id, chain_id, path, xpub, address, script_type, balance, balance_usd, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id, chain_id, path) DO UPDATE SET + xpub = excluded.xpub, + address = CASE WHEN excluded.address != '' THEN excluded.address ELSE address END, + script_type = excluded.script_type, + balance = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance ELSE balance END, + balance_usd = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance_usd ELSE balance_usd END, + updated_at = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.updated_at ELSE updated_at END`, [deviceId, chainId, path || '', xpub || '', address || '', scriptType || '', balance || '0', balanceUsd ?? 0, Date.now()] ) } catch (e: any) { @@ -1038,22 +1393,25 @@ export function insertSwapHistory(record: SwapHistoryRecord) { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, device_id, wallet_id, txid, from_asset, to_asset, from_symbol, to_symbol, from_chain_id, to_chain_id, + from_caip, to_caip, from_amount, quoted_output, minimum_output, received_output, slippage_bps, fee_bps, + fee_outbound, integration, swapper, memo, inbound_address, router, status, outbound_txid, + error, created_at, updated_at, completed_at, estimated_time_secs, actual_time_secs, approval_txid, + relay_request_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - record.id, record.txid, record.fromAsset, record.toAsset, + record.id, record.deviceId || null, record.walletId || null, record.txid, record.fromAsset, record.toAsset, record.fromSymbol, record.toSymbol, record.fromChainId, record.toChainId, + record.fromCaip || null, record.toCaip || null, record.fromAmount, record.quotedOutput, record.minimumOutput, record.receivedOutput || null, record.slippageBps, record.feeBps, record.feeOutbound, - record.integration, record.memo, record.inboundAddress, + record.integration, record.swapper || null, 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, + record.relayRequestId || null, ] ) } catch (e: any) { @@ -1061,14 +1419,29 @@ export function insertSwapHistory(record: SwapHistoryRecord) { } } -/** Update swap status and related fields (called on every status change) */ +/** Update swap status and related fields (called on every status change). + * + * Field semantics: + * - Truthy values UPDATE the column. + * - `null` values explicitly CLEAR the column (UPDATE … SET col = NULL). + * - `undefined` (or field absent) leaves the column unchanged. + * This three-state distinction matters for `swapper` — once the tracker has + * identified a swap as native-vault (mayachain/thorchain) it needs to wipe + * any stale "thorchain" value Pioneer wrote earlier; an undefined-skip + * pattern would silently fail and leave the badge mis-rendering. + */ export function updateSwapHistoryStatus( txid: string, status: SwapTrackingStatus, extra?: { - outboundTxid?: string + deviceId?: string + walletId?: string + outboundTxid?: string | null + outboundChainId?: string | null + refundReason?: string | null error?: string receivedOutput?: string + swapper?: string | null completedAt?: number actualTimeSeconds?: number } @@ -1084,7 +1457,15 @@ export function updateSwapHistoryStatus( { col: 'updated_at', value: now }, ] - if (extra?.outboundTxid) setClauses.push({ col: 'outbound_txid', value: extra.outboundTxid }) + // Three-state writers: truthy → set, null → clear, undefined → skip. + const writeNullable = (col: string, val: string | null | undefined) => { + if (val === undefined) return + setClauses.push({ col, value: val ?? null }) + } + writeNullable('outbound_txid', extra?.outboundTxid) + writeNullable('outbound_chain_id', extra?.outboundChainId) + writeNullable('refund_reason', extra?.refundReason) + writeNullable('swapper', extra?.swapper) if (extra?.error) setClauses.push({ col: 'error', value: extra.error }) if (extra?.receivedOutput) setClauses.push({ col: 'received_output', value: extra.receivedOutput }) if (isFinal) { @@ -1094,8 +1475,13 @@ export function updateSwapHistoryStatus( } } - const sql = `UPDATE swap_history SET ${setClauses.map(c => `${c.col} = ?`).join(', ')} WHERE txid = ?` - const params = [...setClauses.map(c => c.value), txid] + const where = extra?.walletId ? 'txid = ? AND wallet_id = ?' : extra?.deviceId ? 'txid = ? AND device_id = ?' : 'txid = ?' + const params = extra?.walletId + ? [...setClauses.map(c => c.value), txid, extra.walletId] + : extra?.deviceId + ? [...setClauses.map(c => c.value), txid, extra.deviceId] + : [...setClauses.map(c => c.value), txid] + const sql = `UPDATE swap_history SET ${setClauses.map(c => `${c.col} = ?`).join(', ')} WHERE ${where}` db.run(sql, params) } catch (e: any) { @@ -1115,6 +1501,14 @@ export function getSwapHistory(filter?: SwapHistoryFilter): SwapHistoryRecord[] sql += ` AND status = ?` params.push(filter.status) } + if (filter?.deviceId) { + sql += ` AND device_id = ?` + params.push(filter.deviceId) + } + if (filter?.walletId) { + sql += ` AND wallet_id = ?` + params.push(filter.walletId) + } if (filter?.fromDate) { sql += ` AND created_at >= ?` params.push(filter.fromDate) @@ -1146,10 +1540,14 @@ export function getSwapHistory(filter?: SwapHistoryFilter): SwapHistoryRecord[] } /** Get a single swap history record by txid */ -export function getSwapHistoryByTxid(txid: string): SwapHistoryRecord | null { +export function getSwapHistoryByTxid(txid: string, deviceId?: string, walletId?: string): SwapHistoryRecord | null { try { if (!db) return null - const row = db.query('SELECT * FROM swap_history WHERE txid = ?').get(txid) as any + const row = walletId + ? db.query('SELECT * FROM swap_history WHERE txid = ? AND wallet_id = ?').get(txid, walletId) as any + : deviceId + ? db.query('SELECT * FROM swap_history WHERE txid = ? AND device_id = ?').get(txid, deviceId) as any + : 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) @@ -1158,9 +1556,11 @@ export function getSwapHistoryByTxid(txid: string): SwapHistoryRecord | null { } /** Get aggregate stats for swap history */ -export function getSwapHistoryStats(): SwapHistoryStats { +export function getSwapHistoryStats(deviceId?: string, walletId?: string): SwapHistoryStats { try { if (!db) return { totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 } + const whereSql = walletId ? 'WHERE wallet_id = ?' : deviceId ? 'WHERE device_id = ?' : '' + const args = walletId ? [walletId] : deviceId ? [deviceId] : [] const row = db.query(` SELECT COUNT(*) as total, @@ -1169,7 +1569,8 @@ export function getSwapHistoryStats(): SwapHistoryStats { 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 + ${whereSql} + `).get(...args) as any return { totalSwaps: row?.total || 0, completed: row?.completed || 0, @@ -1186,6 +1587,8 @@ export function getSwapHistoryStats(): SwapHistoryStats { function mapSwapRow(r: any): SwapHistoryRecord { return { id: r.id, + deviceId: r.device_id || undefined, + walletId: r.wallet_id || undefined, txid: r.txid, fromAsset: r.from_asset, toAsset: r.to_asset, @@ -1193,6 +1596,8 @@ function mapSwapRow(r: any): SwapHistoryRecord { toSymbol: r.to_symbol, fromChainId: r.from_chain_id, toChainId: r.to_chain_id, + fromCaip: r.from_caip || undefined, + toCaip: r.to_caip || undefined, fromAmount: r.from_amount, quotedOutput: r.quoted_output, minimumOutput: r.minimum_output, @@ -1201,6 +1606,7 @@ function mapSwapRow(r: any): SwapHistoryRecord { feeBps: r.fee_bps, feeOutbound: r.fee_outbound, integration: r.integration, + swapper: r.swapper || undefined, memo: r.memo, inboundAddress: r.inbound_address, router: r.router || undefined, @@ -1213,6 +1619,22 @@ function mapSwapRow(r: any): SwapHistoryRecord { estimatedTimeSeconds: r.estimated_time_secs, actualTimeSeconds: r.actual_time_secs || undefined, approvalTxid: r.approval_txid || undefined, + relayRequestId: r.relay_request_id || undefined, + outboundChainId: r.outbound_chain_id || undefined, + refundReason: r.refund_reason || undefined, + } +} + +/** Backfill the Relay request id on an existing row (called when refreshSwap + * resolves it lazily via api.relay.link for a legacy swap). */ +export function setSwapRelayRequestId(txid: string, relayRequestId: string, deviceId?: string, walletId?: string) { + try { + if (!db) return + if (walletId) db.run('UPDATE swap_history SET relay_request_id = ? WHERE txid = ? AND wallet_id = ?', [relayRequestId, txid, walletId]) + else if (deviceId) db.run('UPDATE swap_history SET relay_request_id = ? WHERE txid = ? AND device_id = ?', [relayRequestId, txid, deviceId]) + else db.run('UPDATE swap_history SET relay_request_id = ? WHERE txid = ?', [relayRequestId, txid]) + } catch (e: any) { + console.warn('[db] setSwapRelayRequestId failed:', e.message) } } @@ -1284,4 +1706,3 @@ export function deleteBip85Seed(wordCount: number, index: number, fingerprint?: console.warn('[db] deleteBip85Seed failed:', e.message) } } - diff --git a/projects/keepkey-vault/src/bun/emulator-keychain.ts b/projects/keepkey-vault/src/bun/emulator-keychain.ts index dffbfdc0..a16a0c44 100644 --- a/projects/keepkey-vault/src/bun/emulator-keychain.ts +++ b/projects/keepkey-vault/src/bun/emulator-keychain.ts @@ -28,9 +28,37 @@ function getStorageDir(): string { return dir } +/** + * Validate a wallet name and return the canonical (trimmed) form. + * + * Wallet names flow from RPC callers (UI, REST) into filesystem paths via + * getFlashPath/getMnemonicPath. Without validation here, a malicious or + * buggy caller can use names like "../foo" or "../../etc/something" to + * read/write/delete files outside ~/.keepkey/emulator. emulatorImportWallet + * validates at the entry point but emulatorInit/SwitchWallet/DeleteFlash + * historically did not — keeping validation at the path builders means + * every path is sanitized regardless of caller. + * + * Throws on any name that could escape the storage dir or collide with the + * mnemonic-side suffix. + */ +export function validateFlashName(name: string): string { + if (typeof name !== 'string') throw new Error('Wallet name must be a string') + const trimmed = name.trim() + if (!trimmed || trimmed.length > 64) throw new Error('Wallet name must be 1-64 characters') + if (/[\/\\]/.test(trimmed)) throw new Error('Wallet name cannot contain path separators') + if (trimmed.includes('..')) throw new Error('Wallet name cannot contain ".."') + if (trimmed.includes('\0')) throw new Error('Wallet name cannot contain null bytes') + // ".mnemonic" anywhere — without this, name "foo.mnemonic" produces + // "foo.mnemonic.enc" which collides exactly with getMnemonicPath('foo') + // and is also hidden from listFlashImages's .mnemonic. filter. + if (/\.mnemonic\b/i.test(trimmed)) throw new Error('Wallet name cannot contain ".mnemonic"') + return trimmed +} + /** Path to an encrypted flash image */ export function getFlashPath(name = 'default'): string { - return join(getStorageDir(), `${name}.enc`) + return join(getStorageDir(), `${validateFlashName(name)}.enc`) } // ── Keychain Operations ───────────────────────────────────────────────── @@ -247,7 +275,7 @@ export function deleteFlash(name: string): boolean { /** Path to an encrypted mnemonic file */ function getMnemonicPath(flashName: string): string { - return join(getStorageDir(), `${flashName}.mnemonic.enc`) + return join(getStorageDir(), `${validateFlashName(flashName)}.mnemonic.enc`) } /** diff --git a/projects/keepkey-vault/src/bun/emulator-transport.ts b/projects/keepkey-vault/src/bun/emulator-transport.ts index 9c91c0a5..d1dea102 100644 --- a/projects/keepkey-vault/src/bun/emulator-transport.ts +++ b/projects/keepkey-vault/src/bun/emulator-transport.ts @@ -17,8 +17,12 @@ const TAG = '[emu-transport]' // Poll interval for non-blocking emuRead (ms) const READ_POLL_MS = 5 -// Default read timeout (2 minutes — matches hdwallet DEFAULT_TIMEOUT) -const READ_TIMEOUT_MS = 120_000 +// Read timeout MUST outlive the emulator confirm prompt (CONFIRM_TIMEOUT_MS, +// 120s) plus the firmware roundtrip — fn() runs before the user is asked +// to approve, so the readChunk deadline is ticking while the user thinks. +// At 240s the user has up to 120s to decide, plus another ~120s for the +// firmware to process the approval and emit the response. +const READ_TIMEOUT_MS = 240_000 export class EmulatorTransportDelegate implements TransportDelegate { private connected = false @@ -185,6 +189,9 @@ function buildHidFrame(msgType: number, payload: Uint8Array = new Uint8Array(0)) const BUTTON_ACK_FRAME = buildHidFrame(27) // DebugLinkDecision (type 100 = 0x0064) — yes_no=true: protobuf field 1 varint = [0x08, 0x01] const DEBUG_LINK_DECISION_YES = buildHidFrame(100, new Uint8Array([0x08, 0x01])) +// Cancel (type 20 = 0x0014) — no payload. confirm_helper's tiny-msg switch +// has an explicit case for Cancel that exits with ret_stat=false. +const CANCEL_FRAME = buildHidFrame(20) /** * Pre-write N button confirmations into the emulator ring buffers. @@ -205,3 +212,18 @@ export function prewriteConfirmations(count: number): void { } } +/** + * Pre-queue a Cancel frame on iface 0 (main). + * + * Use before draining the input ring on user reject: when the queued sign + * chunk gets consumed and the firmware enters confirm_helper, its tiny-msg + * loop reads the Cancel and exits with ret_stat=false instead of busy- + * looping forever waiting for BA+DLD that never come (which would trigger + * the watchdog SIGKILL at 60s). + */ +export function prewriteCancel(): void { + console.log(`${TAG} Pre-writing Cancel on iface 0`) + emuWrite(CANCEL_FRAME, 0) +} + + diff --git a/projects/keepkey-vault/src/bun/emulator-watchdog.ts b/projects/keepkey-vault/src/bun/emulator-watchdog.ts index e3917a0a..c5b4d660 100644 --- a/projects/keepkey-vault/src/bun/emulator-watchdog.ts +++ b/projects/keepkey-vault/src/bun/emulator-watchdog.ts @@ -35,6 +35,13 @@ export function startEmulatorWatchdog(): void { // arming the heartbeat — otherwise a failed spawn leaves an orphaned // setInterval writing to a file with no killer watching it. try { + // 60s deadline. The 7.15 firmware adds Zcash Orchard + BIP-85 derivation + // paths that, on first call, can take 5-15s in the dylib (no daemon poll + // thread; the caller is the only thing driving kkemu_poll while a deep + // derivation runs in the firmware). 15s wasn't enough headroom and + // turned slow-but-working flows into kill-the-app crashes. Keep the + // watchdog as a backstop against genuine freezes (confirm_helper-style + // busy loops), just give legit work room to finish. watchdogProc = Bun.spawn(['bash', '-c', ` while true; do sleep 5 @@ -42,7 +49,7 @@ export function startEmulatorWatchdog(): void { last=$(cat "${HEARTBEAT_FILE}" 2>/dev/null || echo 0) now=$(date +%s) age=$(( now - last / 1000 )) - if [ "$age" -gt 15 ]; then + if [ "$age" -gt 60 ]; then kill -9 ${process.pid} 2>/dev/null rm -f "${HEARTBEAT_FILE}" exit 0 @@ -71,7 +78,7 @@ export function startEmulatorWatchdog(): void { } started = true - console.log('[EmuWatchdog] Started — will SIGKILL if emulator FFI freezes event loop >15s') + console.log('[EmuWatchdog] Started — will SIGKILL if emulator FFI freezes event loop >60s') } export function stopEmulatorWatchdog(): void { diff --git a/projects/keepkey-vault/src/bun/emulator-window.ts b/projects/keepkey-vault/src/bun/emulator-window.ts index 49c57475..b8b9084b 100644 --- a/projects/keepkey-vault/src/bun/emulator-window.ts +++ b/projects/keepkey-vault/src/bun/emulator-window.ts @@ -24,13 +24,22 @@ const STATE_FILE = join(STATE_DIR, 'window-state.json') interface WindowState { x: number; y: number; width: number; height: number } -const DEFAULT_STATE: WindowState = { x: 50, y: 50, width: 380, height: 260 } +const DEFAULT_STATE: WindowState = { x: 50, y: 50, width: 400, height: 380 } +const MIN_WIDTH = 320 +const MIN_HEIGHT = 360 // header + OLED + confirm meta + buttons function loadWindowState(): WindowState { try { if (existsSync(STATE_FILE)) { const data = JSON.parse(readFileSync(STATE_FILE, 'utf-8')) - if (data.x != null && data.y != null && data.width > 0 && data.height > 0) return data + if (data.x != null && data.y != null && data.width > 0 && data.height > 0) { + return { + x: data.x, + y: data.y, + width: Math.max(MIN_WIDTH, data.width), + height: Math.max(MIN_HEIGHT, data.height), + } + } } } catch {} return { ...DEFAULT_STATE } @@ -71,11 +80,25 @@ let pendingConfirm: { resolve: (approved: boolean) => void } | null = null -/** Pending seed ack — resolved when the webview POSTs to the bridge */ +/** + * Pending seed ack — resolved on explicit "I've recorded my words" click, + * rejected if the user closes the window without acking. Without the + * reject path, the OS close button looks identical to an ack and the + * wizard advances even though the user never confirmed they wrote down + * the recovery phrase. + */ let pendingSeedAck: { resolve: () => void + reject: (err: Error) => void } | null = null +/** + * True once the webview has POSTed /_emu/ready. Until then, `sendToWindow` + * drops messages — calling executeJavascript on a not-yet-loaded WebView + * crashes the WebContent process (EXC_BREAKPOINT in WebPageProxy launch). + */ +let viewReady = false + function startBridge(): number { if (bridgeServer) return bridgePort @@ -97,6 +120,12 @@ function startBridge(): number { }) } + if (url.pathname === '/_emu/ready' && req.method === 'POST') { + viewReady = true + console.log(`${TAG} Bridge: webview signaled ready`) + return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*' } }) + } + if (url.pathname === '/_emu/seed-ack' && req.method === 'POST') { console.log(`${TAG} Bridge: seed acknowledged`) if (pendingSeedAck) { @@ -180,10 +209,14 @@ export function openEmulatorWindow(): void { pendingConfirm = null } if (pendingSeedAck) { - pendingSeedAck.resolve() + // Closing the window mid-seed-display is NOT an ack — caller must + // see this as failure so it can roll back the saved mnemonic and + // not advance the wizard with an unbacked-up wallet. + pendingSeedAck.reject(new Error('Emulator window closed before seed words were acknowledged')) pendingSeedAck = null } emuWindow = null + viewReady = false }) startDisplayPoll() @@ -201,8 +234,13 @@ export function closeEmulatorWindow(): void { pendingConfirm.resolve(false) pendingConfirm = null } + if (pendingSeedAck) { + pendingSeedAck.reject(new Error('Emulator window closed before seed words were acknowledged')) + pendingSeedAck = null + } try { emuWindow.close() } catch {} emuWindow = null + viewReady = false stopBridge() } @@ -212,33 +250,68 @@ export function isEmulatorWindowOpen(): boolean { // ── Send to webview (bun → webview via executeJavascript) ─────────────── -function sendToWindow(messageName: string, payload: any): void { - if (!emuWindow) return +/** + * Push a message into the webview via executeJavascript. + * + * Returns true if the call was issued (window present + viewReady + no + * thrown error), false otherwise. Callers that install a pending promise + * keyed off this delivery (e.g. displaySeedWords + pendingSeedAck) MUST + * check the return value — if delivery failed silently and the caller + * still installs the pending state, it can hang forever waiting for a + * webview interaction that the webview never received the request for. + */ +function sendToWindow(messageName: string, payload: any): boolean { + if (!emuWindow || !viewReady) return false const packet = JSON.stringify({ type: 'message', id: messageName, payload }) - emuWindow.webview.executeJavascript(`window.handlePacket(${packet})`) + try { + emuWindow.webview.executeJavascript(`window.handlePacket(${packet})`) + return true + } catch (err: any) { + console.warn(`${TAG} sendToWindow ${messageName} failed:`, err?.message) + return false + } } // ── Seed word display ─────────────────────────────────────────────────── -export function displaySeedWords(mnemonic: string): Promise { - return new Promise((resolve) => { - const words = mnemonic.trim().split(/\s+/) +export async function displaySeedWords(mnemonic: string): Promise { + const words = mnemonic.trim().split(/\s+/) - if (!emuWindow) { - console.warn(`${TAG} No emulator window — skipping seed display`) - resolve() - return + if (!emuWindow) { + console.warn(`${TAG} No emulator window — opening for seed display`) + openEmulatorWindow() + } + + // Wait for the bridge handshake before installing the pending ack — + // otherwise sendToWindow silently no-ops and the awaiter blocks forever. + // Throw on failure so callers (e.g. emulatorCreateWallet) don't tell the + // user "seed displayed" when the words were never actually shown — that + // would lead to backing up a seed the device doesn't hold. + if (!viewReady) { + const deadline = Date.now() + 5000 + while (Date.now() < deadline && !viewReady && emuWindow) { + await new Promise(r => setTimeout(r, 50)) + } + if (!emuWindow || !viewReady) { + throw new Error( + `Emulator window not ready (emuWindow=${!!emuWindow} viewReady=${viewReady}) — cannot display seed` + ) } + } - pendingSeedAck = { resolve } + try { emuWindow!.focus() } catch {} - try { - sendToWindow('seed-display', { words }) - sendToWindow('emu-state', { state: 'seed-display' }) - } catch (err) { - console.error(`${TAG} Failed to send seed-display:`, err) + return new Promise((resolve, reject) => { + pendingSeedAck = { resolve, reject } + + // sendToWindow returns false on delivery failure (window gone, view + // not ready, executeJavascript threw). Without this check, the pending + // ack stays installed and the RPC hangs forever waiting for an "I've + // recorded my words" click on a webview that never got the request. + const delivered = sendToWindow('seed-display', { words }) && sendToWindow('emu-state', { state: 'seed-display' }) + if (!delivered) { pendingSeedAck = null - resolve() + reject(new Error('Failed to deliver seed display to emulator webview')) } }) } @@ -254,8 +327,34 @@ const CONFIRM_TIMEOUT_MS = 120_000 // 2 minutes — reject if emulator window is async function requestUserConfirm(details: EmulatorConfirmDetails & { id: string }): Promise { if (!emuWindow) { - console.error(`${TAG} No emulator window — rejecting (fail closed)`) - return false + // Window may have been dismissed (user clicked the OS close button) but + // the engine is still connected — re-open it so signing can proceed. + // This can also happen on the very first sign after a fresh start when + // the wizard's transitions raced the window's first paint. + console.warn(`${TAG} No emulator window for confirm — re-opening`) + openEmulatorWindow() + } + + // Wait for the bridge handshake regardless of whether we just opened the + // window or it was already up. A window can exist with viewReady=false + // immediately after open (HTML hasn't loaded yet) — sendToWindow would + // silently no-op and the user would never see the prompt. + if (!viewReady) { + const deadline = Date.now() + 5000 + while (Date.now() < deadline && !viewReady && emuWindow) { + await new Promise(r => setTimeout(r, 50)) + } + if (!emuWindow || !viewReady) { + console.error(`${TAG} Emulator window not ready (emuWindow=${!!emuWindow} viewReady=${viewReady}) — rejecting`) + return false + } + } + + // Bring the emu window to front so the user actually sees the prompt. + // Without this, a user focused on the dashboard never realizes a sign + // is waiting on a click in another window. + try { emuWindow!.focus() } catch (e: any) { + console.warn(`${TAG} focus() failed:`, e?.message) } return new Promise((resolve) => { @@ -292,33 +391,63 @@ function sendDismiss(): void { } // ── Display polling (real OLED framebuffer) ───────────────────────────── +// +// Strategy: the dylib's libkkemu_capture_frame() callback fires from inside +// every display_refresh() — including the ones inside confirm_helper's busy +// loop within a single synchronous kkemu_poll() call. Those frames would +// otherwise be invisible to JS (the canvas has reverted to home by the time +// kkemu_poll returns). +// +// Each poll tick: drain the C-side capture ring into our local playback +// queue, then emit one frame to the webview. This gives the user a visible +// playback of intermediate screens (confirm, cipher, recovery) at ~15fps. +// The C-side ring dedupes adjacent identical frames so an idle firmware +// doesn't fill the queue. let displayPollTimer: ReturnType | null = null -let cachedGetDisplay: (() => { framebuffer: Uint8Array | null; width: number; height: number }) | null = null +let cachedPopFrames: (() => Uint8Array[]) | null = null +const playbackQueue: Uint8Array[] = [] +const PLAYBACK_QUEUE_CAP = 90 // ~6s at 15fps; older frames dropped export function startDisplayPoll(): void { if (displayPollTimer) return import('./emulator').then(mod => { - cachedGetDisplay = mod.emuGetDisplay + cachedPopFrames = mod.emuPopFrames let lastHadDisplay = false displayPollTimer = setInterval(() => { - if (!emuWindow || !cachedGetDisplay) return - const { framebuffer, width, height } = cachedGetDisplay() - if (framebuffer && width > 0 && height > 0) { + if (!emuWindow || !cachedPopFrames) return + + // Always drain the C ring so the dylib doesn't overflow during the + // bridge handshake. Frames captured before viewReady are held in the + // playback queue (capped, oldest dropped) and start emitting as soon + // as the webview is up — without this hold, setup-period OLED frames + // (boot, wipe, recovery cipher prompts) were silently lost. + const fresh = cachedPopFrames() + if (fresh.length > 0) { + playbackQueue.push(...fresh) + while (playbackQueue.length > PLAYBACK_QUEUE_CAP) playbackQueue.shift() + } + + // sendToWindow is a no-op until viewReady. Don't shift off the queue + // until then — emitted frames would be discarded mid-flight. + if (!viewReady) return + + if (playbackQueue.length > 0) { + const fb = playbackQueue.shift()! + const b64 = Buffer.from(fb).toString('base64') + sendToWindow('display-update', { fb: b64, w: 256, h: 64 }) lastHadDisplay = true - const b64 = Buffer.from(framebuffer).toString('base64') - sendToWindow('display-update', { fb: b64, w: width, h: height }) - } else if (lastHadDisplay) { - lastHadDisplay = false - sendToWindow('display-lost', {}) } + // No queued frame: leave the last frame on screen. (Don't emit + // display-lost; the device hasn't gone away, it's just idle.) }, 66) // ~15fps }) } export function stopDisplayPoll(): void { if (displayPollTimer) { clearInterval(displayPollTimer); displayPollTimer = null } - cachedGetDisplay = null + cachedPopFrames = null + playbackQueue.length = 0 } // Firmware confirmation counts by operation type. @@ -356,12 +485,29 @@ function getConfirmCount(details: EmulatorConfirmDetails): number { /** * Interactive confirm wrapper for emulator signing/address operations. * + * Order preserves the HW-wallet review pattern: fn() starts FIRST so the + * firmware can render its OLED screens (visible via the dylib frame + * capture ring) BEFORE the user is asked to approve — the user reviews + * what the device drew, not what the host claims. + * * 1. Pause poll, start operation (chunks queue in ring buffer) - * 2. Poll N-1 times (consume all but last chunk) - * 3. Send confirm-request to emulator window + * 2. Pre-poll N-1 (consume all but the last chunk; firmware accumulates + * but doesn't dispatch yet — confirm_helper isn't entered) + * 3. Show confirm prompt with details; user can also see captured OLED + * frames in the playback queue from the pre-polls * 4. Wait for user Confirm/Reject (arrives via bridge HTTP POST) - * 5. If approved: prewriteConfirmations(N) → final poll → return result - * 6. If rejected: throw error + * 5. If approved: prewriteConfirmations(N), final poll -> firmware reads + * the Nth chunk, dispatches, enters confirm_helper, draws screen, + * sees pre-written BA+DLD, exits cleanly, returns response + * 6. If rejected: prewriteCancel, flushRingBuffers -> the queued Nth + * chunk gets consumed and triggers confirm_helper, which sees Cancel + * in its tiny-msg switch and exits ret_stat=false. Without Cancel + * waiting, confirm_helper would busy-loop until the watchdog SIGKILLs. + * + * The transport's READ_TIMEOUT_MS is sized to outlive both the confirm + * timeout AND the firmware roundtrip so a late-but-valid approval doesn't + * race the readChunk deadline. POLL_SAFETY_MS likewise outlives the + * confirm timeout so the paused poll doesn't auto-resume mid-decision. * * CRITICAL: kkemu_poll() blocks inside confirm_helper(). The firmware may * call confirm_helper() multiple times per operation (e.g. ETH: data + fee). @@ -373,7 +519,7 @@ export async function emuInteractiveConfirm( engineDelegate?: { chunkCount: number; autoConfirm?: boolean } | null, ): Promise { const { pausePoll, resumePoll, saveEmulatorState, emuPollOnce, flushRingBuffers } = await import('./emulator') - const { prewriteConfirmations } = await import('./emulator-transport') + const { prewriteConfirmations, prewriteCancel } = await import('./emulator-transport') const id = crypto.randomUUID() if (engineDelegate) engineDelegate.chunkCount = 0 @@ -381,12 +527,16 @@ export async function emuInteractiveConfirm( pausePoll() try { + // Start the wallet op — transport writes N chunks into the ring buffer. const promise = fn() - await new Promise(r => setTimeout(r, 30)) + await new Promise(r => setTimeout(r, 30)) // let transport flush all writes const numChunks = engineDelegate?.chunkCount || 1 console.log(`${TAG} ${numChunks} chunks written, polling ${numChunks - 1} pre-polls`) + // Drive the firmware up to (but not into) confirm_helper. Pre-polling + // N-1 chunks lets it accumulate the message but defer dispatch until + // the final chunk arrives — so the JS thread isn't blocked. for (let i = 0; i < numChunks - 1; i++) { emuPollOnce() } @@ -396,9 +546,19 @@ export async function emuInteractiveConfirm( console.log(`${TAG} User responded: approved=${approved}`) if (!approved) { - // Flush the last queued chunk + any stale data so the next background - // kkemu_poll() doesn't enter confirm_helper and spin forever. + // Pre-queue Cancel BEFORE flushing. flushRingBuffers calls kkemu_poll + // which consumes the Nth (queued) sign chunk and triggers confirm_helper. + // Without Cancel waiting in the ring, confirm_helper busy-loops forever + // for BA+DLD that never come and the watchdog SIGKILLs bun. Cancel is + // in confirm_helper's tiny-msg switch (case MessageType_Cancel -> + // ret_stat=false, goto exit), so the firmware exits cleanly. + prewriteCancel() flushRingBuffers() + // The underlying wallet op is still pending — it'll reject once the + // transport reads the firmware's Failure response triggered by Cancel. + // Attach a no-op catch so the eventual rejection isn't surfaced as an + // UnhandledPromiseRejection after we throw the user-facing error. + promise.catch(() => {}) throw new Error('Transaction rejected by user on emulator') } @@ -518,10 +678,20 @@ function buildEmulatorHTML(bridgePort: number): string { word-break: break-all; padding: 8px 12px; } - .oled-content .op-label { color: #4fc3f7; font-weight: bold; font-size: 12px; margin-bottom: 4px; } - .oled-content .detail { color: #ccc; font-size: 10px; } - .oled-content .addr { color: #81c784; font-size: 10px; font-family: 'Courier New', monospace; } .idle-text { color: #666; font-size: 12px; } + .confirm-meta { + display: none; + padding: 8px 14px 4px; + font-family: 'Courier New', monospace; + font-size: 11px; + line-height: 1.5; + color: #ddd; + text-align: center; + } + .confirm-meta.visible { display: block; } + .confirm-meta .op-label { color: #4fc3f7; font-weight: bold; font-size: 13px; margin-bottom: 4px; } + .confirm-meta .detail { color: #ccc; font-size: 11px; } + .confirm-meta .addr { color: #81c784; font-size: 11px; word-break: break-all; } .buttons { display: none; padding: 10px 16px 14px; gap: 12px; justify-content: center; } .buttons.visible { display: flex; } .btn { @@ -570,6 +740,7 @@ function buildEmulatorHTML(bridgePort: number): string { +
Recovery Phrase
@@ -593,6 +764,7 @@ function buildEmulatorHTML(bridgePort: number): string { var seedAckBtn = document.getElementById('seedAckBtn'); var oledCanvas = document.getElementById('oledCanvas'); var oledCtx = oledCanvas.getContext('2d'); + var confirmMeta = document.getElementById('confirmMeta'); var hasRealDisplay = false; var currentConfirmId = null; @@ -665,30 +837,28 @@ function buildEmulatorHTML(bridgePort: number): string { function onConfirmRequest(details) { console.log('[emu-ui] Confirm request: op=' + details.operation + ' id=' + details.id); currentConfirmId = details.id; - if (!hasRealDisplay) { - var opName = details.operation - .replace(/([A-Z])/g, ' $$1').replace(/^ /, '') - .replace('Sign Tx', 'Sign Transaction') - .replace('Get Address', 'Verify Address'); - var html = '
' + esc(opName) + '
'; - if (details.chain) html += '
Chain: ' + esc(details.chain) + '
'; - if (details.to) { - var addr = details.to; - if (addr.length > 20) addr = addr.slice(0, 10) + '...' + addr.slice(-8); - html += '
To: ' + esc(addr) + '
'; - } - if (details.value) html += '
Amount: ' + esc(details.value) + '
'; - if (details.memo) html += '
Memo: ' + esc(details.memo) + '
'; - oled.innerHTML = html; + var opName = details.operation + .replace(/([A-Z])/g, ' $$1').replace(/^ /, '') + .replace('Sign Tx', 'Sign Transaction') + .replace('Get Address', 'Verify Address'); + var html = '
' + esc(opName) + '
'; + if (details.chain) html += '
Chain: ' + esc(details.chain) + '
'; + if (details.to) { + var addr = details.to; + if (addr.length > 24) addr = addr.slice(0, 12) + '...' + addr.slice(-10); + html += '
To: ' + esc(addr) + '
'; } + if (details.value) html += '
Amount: ' + esc(details.value) + '
'; + if (details.memo) html += '
Memo: ' + esc(details.memo) + '
'; + confirmMeta.innerHTML = html; + confirmMeta.classList.add('visible'); buttons.classList.add('visible'); } function onConfirmDismiss() { currentConfirmId = null; - if (!hasRealDisplay) { - oled.innerHTML = '
KeepKey Emulator Ready
'; - } + confirmMeta.innerHTML = ''; + confirmMeta.classList.remove('visible'); buttons.classList.remove('visible'); } @@ -729,9 +899,7 @@ function buildEmulatorHTML(bridgePort: number): string { if (!currentConfirmId) return; console.log('[emu-ui] CONFIRM clicked'); postBridge('/_emu/confirm', { id: currentConfirmId, approved: true }); - if (!hasRealDisplay) { - oled.innerHTML = '
Processing...
'; - } + confirmMeta.innerHTML = '
Processing…
'; buttons.classList.remove('visible'); }); @@ -739,13 +907,12 @@ function buildEmulatorHTML(bridgePort: number): string { if (!currentConfirmId) return; console.log('[emu-ui] REJECT clicked'); postBridge('/_emu/confirm', { id: currentConfirmId, approved: false }); - if (!hasRealDisplay) { - oled.innerHTML = '
Rejected
'; - setTimeout(function() { - oled.innerHTML = '
KeepKey Emulator Ready
'; - }, 1500); - } + confirmMeta.innerHTML = '
Rejected
'; buttons.classList.remove('visible'); + setTimeout(function() { + confirmMeta.innerHTML = ''; + confirmMeta.classList.remove('visible'); + }, 1200); }); seedAckBtn.addEventListener('click', function() { @@ -755,6 +922,10 @@ function buildEmulatorHTML(bridgePort: number): string { }); console.log('[emu-ui] Ready, bridge=' + BRIDGE); + // Tell bun the WebView is ready to receive executeJavascript packets. + // Without this, display-update polls fire before window.handlePacket is + // defined and crash the WKWebView process (EXC_BREAKPOINT in WebKit). + postBridge('/_emu/ready', {}); })(); diff --git a/projects/keepkey-vault/src/bun/emulator.ts b/projects/keepkey-vault/src/bun/emulator.ts index 6fd622bf..5408ee05 100644 --- a/projects/keepkey-vault/src/bun/emulator.ts +++ b/projects/keepkey-vault/src/bun/emulator.ts @@ -7,15 +7,17 @@ * Architecture: * Bun process * ├─ emulator-keychain.ts — Keychain key + AES-256-GCM encrypt/decrypt - * ├─ emulator.ts (this) — flash lifecycle, FFI bridge, version selection + * ├─ emulator.ts (this) — flash lifecycle, FFI bridge * └─ libkkemu.dylib — firmware as shared library (loaded via bun:ffi) * - * Emulator binaries are bundled at: firmware/emulators//libkkemu.dylib - * Manifest at: firmware/emulators/manifest.json + * The dylib is user-installed at ~/.keepkey/emulator/libkkemu.dylib — + * dropped onto the app via FileDropZone, or copied there by `make + * build-emulator`. No channel/version system: one slot, one binary. */ -import { dlopen, FFIType, ptr, toBuffer } from 'bun:ffi' -import { resolve, join, dirname } from 'path' -import { existsSync, readFileSync } from 'fs' +import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi' +import { join } from 'path' +import { existsSync, mkdirSync } from 'fs' +import { homedir } from 'os' import { isMacOS, getOrCreateKey, getPairingStatus, loadFlash, saveFlash, zeroFlash, listFlashImages, deleteFlash, @@ -27,121 +29,23 @@ import type { EmulatorStatus, EmulatorProcessState } from '../shared/types' const TAG = '[emulator]' const FLASH_SIZE = 1048576 // 1 MB -// ── Emulator manifest ─────────────────────────────────────────────────── +// ── Dylib resolution (single user-installed slot) ─────────────────────── -interface EmulatorSource { - repo: string - ref: string - type: 'branch' | 'commit' +/** Directory for the user-installed emulator binary. */ +function getEmulatorBinDir(): string { + const dir = join(homedir(), '.keepkey', 'emulator') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }) + return dir } -interface EmulatorEntry { - version: string - firmwareVersion: string - channel: string - arch: string - platform: string - dylib: string - binary: string - debugLink: boolean - description: string - source: EmulatorSource +/** Path to the user-installed dylib. May not exist yet. */ +export function getDylibPath(): string { + return join(getEmulatorBinDir(), 'libkkemu.dylib') } -interface EmulatorManifest { - emulators: EmulatorEntry[] - default: string -} - -export type EmulatorChannel = 'alpha' | 'beta' | 'release' - -let _emuDirCache: string | null = null -function getEmulatorsDir(): string { - if (_emuDirCache) return _emuDirCache - // firmware/emulators/ lives at the vault-v11 project root, which is - // outside the Electrobun .app bundle. Walk up from import.meta.dir - // (app/bun/) through the .app structure to find it. - const candidates: string[] = [] - // Walk 2..12 levels up from import.meta.dir — covers source tree, - // dev .app bundle, and production .app bundle depths. - for (let depth = 2; depth <= 12; depth++) { - candidates.push(resolve(import.meta.dir, ...Array(depth).fill('..'), 'firmware', 'emulators')) - } - // Also try cwd-relative - candidates.push(resolve(process.cwd(), 'firmware', 'emulators')) - candidates.push(resolve(process.cwd(), '..', '..', 'firmware', 'emulators')) - - for (const dir of candidates) { - if (existsSync(join(dir, 'manifest.json'))) { - _emuDirCache = dir - console.log(`${TAG} Emulators dir resolved: ${dir}`) - return dir - } - } - console.error(`${TAG} Could not find firmware/emulators/manifest.json (tried ${candidates.length} paths from import.meta.dir=${import.meta.dir})`) - return candidates[0] -} - -function loadManifest(): EmulatorManifest | null { - const manifestPath = join(getEmulatorsDir(), 'manifest.json') - if (!existsSync(manifestPath)) return null - try { - return JSON.parse(readFileSync(manifestPath, 'utf-8')) - } catch { return null } -} - -export function getAvailableEmulators(): EmulatorEntry[] { - const manifest = loadManifest() - if (!manifest) return [] - return manifest.emulators.filter(e => e.platform === process.platform && e.arch === process.arch) -} - -/** Get available channels with their installation status. */ -export function getEmulatorChannels(): Array<{ - channel: EmulatorChannel - version: string - description: string - installed: boolean - source: EmulatorSource -}> { - const manifest = loadManifest() - if (!manifest) return [] - return manifest.emulators - .filter(e => e.platform === process.platform && e.arch === process.arch) - .map(e => ({ - channel: e.channel as EmulatorChannel, - version: e.version, - description: e.description, - installed: existsSync(join(getEmulatorsDir(), e.dylib)), - source: e.source, - })) -} - -/** Find the emulator entry for a given channel. */ -function getEntryByChannel(channel: EmulatorChannel): EmulatorEntry | null { - const manifest = loadManifest() - if (!manifest) return null - return manifest.emulators.find( - e => e.channel === channel && e.platform === process.platform && e.arch === process.arch - ) || null -} - -function getDylibPath(version?: string): string | null { - const manifest = loadManifest() - if (!manifest) return null - const ver = version || manifest.default - const entry = manifest.emulators.find(e => e.version === ver) - if (!entry) return null - const fullPath = join(getEmulatorsDir(), entry.dylib) - return existsSync(fullPath) ? fullPath : null -} - -/** Resolve dylib path from a channel name. */ -function getDylibPathByChannel(channel: EmulatorChannel): string | null { - const entry = getEntryByChannel(channel) - if (!entry) return null - const fullPath = join(getEmulatorsDir(), entry.dylib) - return existsSync(fullPath) ? fullPath : null +/** True when the user has installed a dylib. */ +export function isDylibInstalled(): boolean { + return existsSync(getDylibPath()) } // ── FFI Handle ────────────────────────────────────────────────────────── @@ -158,6 +62,7 @@ function loadDylib(path: string) { kkemu_poll: { args: [], returns: FFIType.i32 }, kkemu_is_running: { args: [], returns: FFIType.i32 }, kkemu_get_display: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr }, + kkemu_pop_frame: { args: [FFIType.ptr], returns: FFIType.i32 }, }) } @@ -165,14 +70,6 @@ function loadDylib(path: string) { let activeFlash: EmulatorFlash | null = null let activeFlashName: string = 'default' -let activeVersion: string | null = null -let activeChannel: EmulatorChannel | null = null -/** - * The user's selected channel — persists across stop/start cycles. - * Set when the user explicitly picks a channel via emulatorInit(channel). - * Re-used by import/switch/restore flows that restart without an explicit channel. - */ -let selectedChannel: EmulatorChannel | null = null let emuState: EmulatorProcessState = 'stopped' let emuError: string | undefined @@ -181,14 +78,13 @@ export function getActiveFlashName(): string { return activeFlashName } // ── Status ────────────────────────────────────────────────────────────── -export function getEmulatorStatus(): EmulatorStatus & { channel?: EmulatorChannel } { +export function getEmulatorStatus(): EmulatorStatus { const pairing = getPairingStatus() return { state: emuState, bridgeReady: emuState === 'running' && ffi !== null, - host: activeVersion ? `libkkemu (${activeVersion})` : 'not loaded', + host: ffi ? 'libkkemu' : 'not loaded', error: emuError, - channel: activeChannel ?? undefined, ...pairing, } } @@ -209,23 +105,20 @@ export function pairEmulator(): EmulatorPairingStatus { /** * Initialize the emulator: * 1. Decrypt flash into memory (or create fresh) - * 2. Load libkkemu.dylib for the selected firmware version/channel + * 2. Load the user-installed libkkemu.dylib at ~/.keepkey/emulator/ * 3. Pass flash buffer to kkemu_init() via FFI * 4. Start poll timer * * @param flashName - Name of the flash image to use (default: 'default') - * @param version - Specific version string (e.g. '7.14.0-alpha') - * @param channel - Channel shorthand: 'alpha' | 'beta' | 'release' - * If channel is provided, it overrides version. */ -export function initEmulator(flashName = 'default', version?: string, channel?: EmulatorChannel): EmulatorStatus { +export function initEmulator(flashName = 'default'): EmulatorStatus { if (!isMacOS()) { emuError = 'Emulator requires macOS' return getEmulatorStatus() } if (activeFlash && ffi) { - console.log(`${TAG} Emulator already running (${activeVersion}, channel=${activeChannel})`) + console.log(`${TAG} Emulator already running`) return getEmulatorStatus() } @@ -234,7 +127,14 @@ export function initEmulator(flashName = 'default', version?: string, channel?: emuError = undefined activeFlashName = flashName - // 1. Decrypt flash + // 1. Locate dylib BEFORE touching flash — failing early avoids creating + // an orphan flash file when the user hasn't installed an emulator yet. + const dylibPath = getDylibPath() + if (!isDylibInstalled()) { + throw new Error(`No emulator installed. Drop a libkkemu.dylib onto the window or run: make build-emulator`) + } + + // 2. Decrypt flash activeFlash = loadFlash(flashName) console.log(`${TAG} Flash loaded: ${flashName} (${activeFlash.isNew ? 'new' : 'existing'}, ${activeFlash.buffer.length} bytes)`) @@ -242,36 +142,7 @@ export function initEmulator(flashName = 'default', version?: string, channel?: saveFlash(activeFlash) } - // 2. Load dylib — resolve channel: explicit arg > sticky selection > version > manifest default - const resolvedChannel = channel ?? selectedChannel - let dylibPath: string | null - if (resolvedChannel) { - dylibPath = getDylibPathByChannel(resolvedChannel) - if (!dylibPath) { - const entry = getEntryByChannel(resolvedChannel) - throw new Error( - entry - ? `Emulator dylib not installed for channel "${resolvedChannel}". Run: make download-emulator-${resolvedChannel}` - : `Unknown emulator channel "${resolvedChannel}". Available: alpha, beta, release` - ) - } - activeChannel = resolvedChannel - if (channel) selectedChannel = channel // explicit pick updates sticky selection - const entry = getEntryByChannel(resolvedChannel)! - activeVersion = entry.version - } else { - dylibPath = getDylibPath(version) - if (!dylibPath) { - throw new Error(`No emulator dylib found for version ${version || 'default'}. Check firmware/emulators/`) - } - activeVersion = version || loadManifest()?.default || 'unknown' - // Infer channel from version - const manifest = loadManifest() - const entry = manifest?.emulators.find(e => e.version === activeVersion) - activeChannel = (entry?.channel as EmulatorChannel) ?? null - } - - console.log(`${TAG} Loading dylib: ${dylibPath} (channel=${activeChannel})`) + console.log(`${TAG} Loading dylib: ${dylibPath}`) ffi = loadDylib(dylibPath) // 3. Pass flash buffer to firmware @@ -291,7 +162,7 @@ export function initEmulator(flashName = 'default', version?: string, channel?: startEmulatorWatchdog() emuState = 'running' - console.log(`${TAG} Emulator running — firmware ${activeVersion}, channel=${activeChannel}, flash "${flashName}"`) + console.log(`${TAG} Emulator running — flash "${flashName}"`) return getEmulatorStatus() } catch (err: any) { emuState = 'error' @@ -304,7 +175,6 @@ export function initEmulator(flashName = 'default', version?: string, channel?: if (pollTimer) { clearInterval(pollTimer); pollTimer = null } if (ffi) { try { ffi.close() } catch {} ; ffi = null } if (activeFlash) { zeroFlash(activeFlash); activeFlash = null } - activeChannel = null return getEmulatorStatus() } @@ -357,8 +227,6 @@ export function stopEmulator(): EmulatorStatus { activeFlash = null } - activeVersion = null - activeChannel = null emuState = 'stopped' emuError = undefined console.log(`${TAG} Emulator stopped, flash encrypted + memory zeroed`) @@ -422,7 +290,14 @@ export function flushRingBuffers(): void { // ── Poll control (for pre-writing confirmations) ──────────────────────── let pollSafetyTimer: ReturnType | null = null -const POLL_SAFETY_MS = 30_000 // auto-resume poll after 30s to prevent permanent stall +// Auto-resume poll after this long to prevent a forgotten resume from +// permanently stalling the firmware. MUST exceed both the confirm prompt +// (CONFIRM_TIMEOUT_MS = 120s) AND the readChunk deadline (READ_TIMEOUT_MS +// = 240s) since fn() runs while the user is deciding and chunks are +// queued in the ring. If safety fires first, the auto-resumed poll +// consumes the queued sign chunk -> confirm_helper enters with no +// prewritten BA/DLD -> busy-loop -> watchdog SIGKILL. +const POLL_SAFETY_MS = 270_000 /** Pause kkemu_poll timer — call before writing messages that trigger confirm. */ export function pausePoll(): void { @@ -459,8 +334,13 @@ export function resumePoll(): void { /** * Read the emulator's 256x64 OLED framebuffer. - * Returns null if the dylib doesn't expose a framebuffer (current alpha returns NULL). - * Call between kkemu_poll() ticks — pointer is valid until next poll. + * Returns null if the dylib doesn't expose a framebuffer. + * + * The returned Uint8Array is a fresh copy. We use `toArrayBuffer + slice()` + * rather than `toBuffer` because Bun's Buffer-from-pointer wrapper attempts + * to free the underlying memory on GC — fine for malloc'd C buffers, but + * the dylib's framebuffer is a static `.bss` page and freeing it segfaults + * the next setInterval tick. */ export function emuGetDisplay(): { framebuffer: Uint8Array | null; width: number; height: number } { if (!ffi) return { framebuffer: null, width: 0, height: 0 } @@ -471,11 +351,38 @@ export function emuGetDisplay(): { framebuffer: Uint8Array | null; width: number const h = heightBuf[0] if (!fbPtr || w === 0 || h === 0) return { framebuffer: null, width: w, height: h } const byteLen = (w * h) / 8 // 2048 bytes for 256x64 1-bit - const framebuffer = new Uint8Array(toBuffer(fbPtr, 0, byteLen)) + // .slice() forces a copy into a JS-owned ArrayBuffer; the borrowed view of + // the dylib's static memory is dropped immediately. + const framebuffer = new Uint8Array(toArrayBuffer(fbPtr, 0, byteLen)).slice() return { framebuffer, width: w, height: h } } +/** + * Pop captured framebuffers from the dylib's display ring. + * + * The firmware's display_refresh() (called every kkemu_poll AND every + * iteration of confirm_helper's busy loop) snapshots the canvas into a + * ring buffer. This drains the ring so the host can replay confirm/init/ + * recovery screens that exist only inside synchronous C calls. + * + * Adjacent identical frames are deduplicated in C, so the returned list + * contains only distinct screen states. Capped per call to avoid + * unbounded JS work if the firmware is animating fast. + */ +const POP_BATCH_CAP = 64 + +export function emuPopFrames(): Uint8Array[] { + if (!ffi) return [] + const frames: Uint8Array[] = [] + const buf = new Uint8Array(2048) + for (let i = 0; i < POP_BATCH_CAP; i++) { + const got = ffi.symbols.kkemu_pop_frame(ptr(buf)) + if (!got) break + frames.push(buf.slice()) + } + return frames +} + // ── Exports ───────────────────────────────────────────────────────────── export { listFlashImages, deleteFlash } -export type { EmulatorEntry, EmulatorManifest } diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index 4dcbe189..b2bf4a2f 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -5,10 +5,11 @@ import * as core from '@keepkey/hdwallet-core' import { HIDKeepKeyAdapter } from '@keepkey/hdwallet-keepkey-nodehid' import { NodeWebUSBKeepKeyAdapter } from '@keepkey/hdwallet-keepkey-nodewebusb' import { usb } from 'usb' -import { saveDeviceSnapshot } from './db' +import { saveDeviceSnapshot, saveEmulatorWalletMeta } from './db' import type { DeviceStateInfo, ActiveTransport, UpdatePhase, DeviceState, FirmwareManifest, PinRequestType, Bip85DeriveParams, Bip85DisplayResult } from '../shared/types' import { resolveOndeviceFirmwareVersion } from '../shared/firmware-versions' import { EmulatorKeepKeyAdapter } from './emulator-transport' +import { getActiveFlashName, getEmulatorStatus } from './emulator' const KEEPKEY_VENDOR_ID = 0x2B24 // 11044 const MANIFEST_URL = 'https://raw.githubusercontent.com/keepkey/keepkey-desktop/master/firmware/releases.json' @@ -86,6 +87,8 @@ function extractErrorMessage(err: any): string { export function withTimeout(promise: Promise, ms: number, label: string): Promise { let timer: ReturnType + // Suppress unhandled rejection on the original promise if the timeout fires first. + promise.catch(() => {}) return Promise.race([ promise, new Promise((_, reject) => { @@ -115,6 +118,10 @@ export class EngineController extends EventEmitter { private retryCount = 0 private static readonly MAX_PAIR_RETRIES = 24 // ~2 minutes at 5s intervals private rebootPollTimer: ReturnType | null = null + // Linux: KeepKey was enumerated on the bus but neither transport could open + // it. Surfaces in DeviceStateInfo so the UI can offer the udev-rules auto-fix. + private linuxUdevPermissionDenied = false + private _loggedBlHash = false // PIN flow tracking — device sends PIN_REQUEST mid-operation private setupInProgress = false @@ -129,6 +136,20 @@ export class EngineController extends EventEmitter { get isEmulator(): boolean { return this.activeTransport === 'emulator' } /** Get the emulator transport delegate (for chunk counting in confirmOp). */ get emuDelegate(): any { return this.isEmulator ? (this.wallet as any)?.transport?.delegate : null } + /** Read-only snapshot used by signing approval UI; avoids transport calls mid-prompt. */ + getCachedFeaturesSnapshot(): any | null { return this.cachedFeatures } + /** Refresh feature policy state after explicit settings/policy mutations. */ + async refreshFeaturesSnapshot(): Promise { + if (!this.wallet) throw new Error('No device connected') + this.cachedFeatures = await this.wallet.getFeatures() + this.updateState(this.deriveState(this.cachedFeatures)) + return this.cachedFeatures + } + /** Drop stale features when a refresh fails so signing UI does not trust old policy state. */ + invalidateFeaturesSnapshot(): void { + this.cachedFeatures = null + this.emit('state-change', this.getDeviceState()) + } constructor() { super() @@ -187,6 +208,10 @@ export class EngineController extends EventEmitter { transport.on(String(core.Events.BUTTON_REQUEST), () => { console.log('[Engine] BUTTON_REQUEST — confirm on device') + // Forward to listeners (e.g. Zcash tab's TxFlowStatus) so the UI can + // distinguish "device computing silently" from "user must press the + // button NOW". Fires globally for any device flow that needs a press. + this.emit('button-request') // Emulator button presses are handled by prewriteConfirmations() in // the RPC handler — NOT here. Sending stale DebugLinkDecision from a // setTimeout poisons the ring buffer and causes "Unexpected message". @@ -252,6 +277,9 @@ export class EngineController extends EventEmitter { } this.clearWallet() this.lastError = null + // Clear the Linux udev flag — otherwise getDeviceState() keeps reporting + // "KeepKey detected, install rules" after the user has unplugged. + this.linuxUdevPermissionDenied = false this.updateState('disconnected') }) console.log('[Engine] USB listeners registered') @@ -325,7 +353,24 @@ export class EngineController extends EventEmitter { if (this.isPassphraseWallet) { console.log('[Engine] Hidden wallet active — skipping device snapshot, seed identity (privacy)') } else if (this.isEmulator) { - console.log('[Engine] Emulator device — skipping device snapshot (emulators use flash images)') + // Emulators get their own metadata table keyed by flash name so the + // splash UI can show label / firmware / USD per wallet without + // contaminating real-device snapshots. Synchronous write — a fire- + // and-forget here would race rollback paths in create/import/load + // that delete this metadata when verification fails. + try { + const features = this.cachedFeatures + const fwVer = this.extractVersion(features) + saveEmulatorWalletMeta( + getActiveFlashName(), + features.label || '', + features.deviceId || '', + fwVer, + '', + ) + } catch (e: any) { + console.warn('[Engine] saveEmulatorWalletMeta failed:', e?.message) + } } else { try { const deviceId = this.cachedFeatures.deviceId || 'unknown' @@ -637,6 +682,24 @@ export class EngineController extends EventEmitter { // Try to pair via WebUSB then HID const result = await this.initializeWallet() + // Linux only: detect "device on the bus, but the OS won't let us open it" + // → udev rules are missing. Two signals, OR'd: + // 1. permissionDenied: pairRawDevice() threw LIBUSB_ERROR_ACCESS / EACCES. + // Primary signal — fires when enumeration succeeds (so usbDetected is + // true) but the open() call inside pairRawDevice() is rejected. + // 2. keepKeyOnBus && !usbDetected: libusb sees the device but neither + // adapter's getDevice() returned anything. Backstop for adapters + // that swallow access errors during enumeration. + // Cleared on any successful pair (early return paths above). + // Track transitions so we can force a state-change emit when the flag + // flips while the device-state itself stays "disconnected" — without + // this the UI never learns the udev rule is missing. + const prevLinuxUdevDenied = this.linuxUdevPermissionDenied + this.linuxUdevPermissionDenied = + process.platform === 'linux' && + (result.permissionDenied || (result.keepKeyOnBus && !result.usbDetected)) + const linuxUdevFlagChanged = this.linuxUdevPermissionDenied !== prevLinuxUdevDenied + if (result.wallet) { this.wallet = result.wallet this.retryCount = 0 @@ -677,6 +740,18 @@ export class EngineController extends EventEmitter { this.lastError = `Failed to read device: ${err}` this.updateState('error') } + } else if (this.linuxUdevPermissionDenied) { + // Linux udev block: enumeration sets result.usbDetected=true even + // when pairRawDevice() failed with EACCES, which would otherwise + // route us into the connected_unpaired+error branch below — and + // App.tsx renders that as the DeviceClaimedDialog, not the splash + // with LinuxUdevWarning. Treat it as disconnected instead so the + // UI hits the splash branch and reads linuxUdevPermissionDenied + // off DeviceStateInfo. updateState() always emits state-change, + // so calling it when lastState is already 'disconnected' still + // refreshes the flag for the renderer. + this.lastError = null + this.updateState('disconnected') } else if (result.usbDetected) { this.lastError = result.error || 'Device detected but cannot be claimed' console.warn(`[Engine] Device seen but not paired: ${this.lastError}`) @@ -692,6 +767,12 @@ export class EngineController extends EventEmitter { } this.lastError = null this.updateState('disconnected') + } else if (linuxUdevFlagChanged) { + // No state transition (still disconnected), but the udev-permission flag + // flipped — push a state-change so the UI can render or clear the + // Linux udev warning. + console.log(`[Engine] linuxUdevPermissionDenied → ${this.linuxUdevPermissionDenied}`) + this.emit('state-change', this.getDeviceState()) } } catch (err) { console.error('[Engine] syncState error:', err) @@ -788,10 +869,32 @@ export class EngineController extends EventEmitter { private async initializeWallet(): Promise<{ wallet: any | undefined usbDetected: boolean + /** True when libusb sees a KeepKey on the bus (vendor 0x2B24) regardless of + * whether we could open it. Used on Linux to distinguish "no device plugged + * in" from "device plugged in but we can't talk to it" (udev rules). */ + keepKeyOnBus: boolean + /** True when an adapter enumerated the device but pairRawDevice() failed + * with a permission/access error (LIBUSB_ERROR_ACCESS / EACCES). On Linux + * this is the canonical "udev rules missing" signal — usbDetected alone + * is not enough, since it gets set on enumeration *before* the failed open. */ + permissionDenied: boolean error: string | null }> { let usbDetected = false + let permissionDenied = false let lastError: string | null = null + const isPermissionError = (msg: string) => + /LIBUSB_ERROR_ACCESS|EACCES|permission denied/i.test(msg) + + // Snapshot the USB bus before trying to open the device. libusb's + // getDeviceList() reads /sys/bus/usb and works for unprivileged users — + // it's the actual open() that needs udev permissions on Linux. + let keepKeyOnBus = false + try { + keepKeyOnBus = usb.getDeviceList().some(d => d.deviceDescriptor.idVendor === KEEPKEY_VENDOR_ID) + } catch (err: any) { + console.warn('[Engine] initializeWallet: getDeviceList() threw:', err?.message || err) + } // Clear stale keyring entries before attempting to pair — without this, // a previous failed pairing leaves the transport in "opened" state and @@ -821,7 +924,7 @@ export class EngineController extends EventEmitter { if (wallet) { this.activeTransport = 'webusb' console.log('[Engine] Paired via WebUSB') - return { wallet, usbDetected: true, error: null } + return { wallet, usbDetected: true, keepKeyOnBus, permissionDenied: false, error: null } } console.warn('[Engine] WebUSB pairRawDevice returned falsy') } catch (err: any) { @@ -830,8 +933,9 @@ export class EngineController extends EventEmitter { // Close the raw USB device so its `opened` flag resets — without this, // the next retry sees opened=true and throws "already-connected". try { await webUsbDevice.close() } catch (_) {} - if (lastError.includes('LIBUSB_ERROR_ACCESS')) { - console.warn('[Engine] Device claimed by another process, trying HID...') + if (isPermissionError(lastError)) { + permissionDenied = true + console.warn('[Engine] WebUSB open denied (likely missing udev rules), trying HID...') } } } @@ -861,20 +965,24 @@ export class EngineController extends EventEmitter { if (wallet) { this.activeTransport = 'hid' console.log('[Engine] Paired via HID') - return { wallet, usbDetected: true, error: null } + return { wallet, usbDetected: true, keepKeyOnBus, permissionDenied: false, error: null } } console.warn('[Engine] HID pairRawDevice returned falsy') } catch (err: any) { lastError = err?.message || String(err) console.warn('[Engine] HID pair failed:', lastError) + if (isPermissionError(lastError)) { + permissionDenied = true + console.warn('[Engine] HID open denied (likely missing udev/hidraw rules)') + } } } } catch (err: any) { console.warn('[Engine] HID getDevice error:', err?.message || err) } - console.log(`[Engine] initializeWallet done — usbDetected=${usbDetected}, error=${lastError}`) - return { wallet: undefined, usbDetected, error: lastError } + console.log(`[Engine] initializeWallet done — usbDetected=${usbDetected}, keepKeyOnBus=${keepKeyOnBus}, permissionDenied=${permissionDenied}, error=${lastError}`) + return { wallet: undefined, usbDetected, keepKeyOnBus, permissionDenied, error: lastError } } // ── Emulator Transport ──────────────────────────────────────────────── @@ -1059,17 +1167,42 @@ export class EngineController extends EventEmitter { ) } - // Verify auto-reload actually took effect - const verifyMnemonic = await this.getEmulatorMnemonic() - if (!verifyMnemonic) { - console.error('[Engine] AUTO-RELOAD VERIFY FAIL — firmware returned no mnemonic') - } else if (verifyMnemonic.trim() !== savedMnemonic.trim()) { - console.error('[Engine] AUTO-RELOAD VERIFY FAIL — firmware has DIFFERENT mnemonic than saved') - console.error('[Engine] saved first word: %s', savedMnemonic.trim().split(/\s+/)[0]) - console.error('[Engine] actual first word: %s', verifyMnemonic.trim().split(/\s+/)[0]) - } else { - console.log('[Engine] AUTO-RELOAD VERIFY OK — firmware mnemonic matches saved seed') - } + // Verify auto-reload actually took effect. Race against a 3s + // deadline — the DebugLinkGetState read can hang on the dylib + // path (separate timing bug). The verify is just a sanity log; + // if it hangs, connectEmulator must NOT block forever or the + // wizard / dashboard never sees state → ready. + // + // The underlying readChunk has its own ~240s timeout and we + // can't cancel it from here, so the .then below may fire long + // after the race resolves. Suppress its log in that case so + // the user doesn't see a spurious VERIFY FAIL minutes later. + let verifyAbandoned = false + const verifyPromise = this.getEmulatorMnemonic() + .then(verifyMnemonic => { + if (verifyAbandoned) return + if (!verifyMnemonic) { + console.error('[Engine] AUTO-RELOAD VERIFY FAIL — firmware returned no mnemonic') + } else if (verifyMnemonic.trim() !== savedMnemonic.trim()) { + console.error('[Engine] AUTO-RELOAD VERIFY FAIL — firmware has DIFFERENT mnemonic than saved') + console.error('[Engine] saved first word: %s', savedMnemonic.trim().split(/\s+/)[0]) + console.error('[Engine] actual first word: %s', verifyMnemonic.trim().split(/\s+/)[0]) + } else { + console.log('[Engine] AUTO-RELOAD VERIFY OK — firmware mnemonic matches saved seed') + } + }) + .catch(err => { + if (verifyAbandoned) return + console.warn('[Engine] AUTO-RELOAD VERIFY error:', err?.message || err) + }) + await Promise.race([ + verifyPromise, + new Promise(resolve => setTimeout(() => { + verifyAbandoned = true + console.warn('[Engine] AUTO-RELOAD VERIFY timed out (3s) — continuing') + resolve() + }, 3000)), + ]) this.updateState(this.deriveState(this.cachedFeatures)) } else { @@ -1191,7 +1324,10 @@ export class EngineController extends EventEmitter { const resolved = this.manifest.hashes.bootloader[blHash] if (resolved) { effectiveBlVersion = resolved.replace(/^v/, '') - console.log(`[Engine] Resolved BL hash ${blHash.slice(0, 8)}… → v${effectiveBlVersion}`) + if (!this._loggedBlHash) { + this._loggedBlHash = true + console.log(`[Engine] Resolved BL hash ${blHash.slice(0, 8)}… → v${effectiveBlVersion}`) + } } } } @@ -1227,6 +1363,7 @@ export class EngineController extends EventEmitter { error: this.lastError, isEmulator: this.activeTransport === 'emulator', isHiddenWallet: this.hiddenWalletActive, + linuxUdevPermissionDenied: this.linuxUdevPermissionDenied || undefined, } } @@ -1894,6 +2031,8 @@ export class EngineController extends EventEmitter { console.log(`[Engine] Seed identity OK: ${addr.slice(0, 10)}...`) } setSetting(key, addr) + this.emit('wallet-scope-ready', { deviceId, seedAddress: addr }) + this.emit('state-change', this.getDeviceState()) } catch (err: any) { console.warn('[Engine] checkSeedIdentity failed:', err?.message) } diff --git a/projects/keepkey-vault/src/bun/evm-addresses.ts b/projects/keepkey-vault/src/bun/evm-addresses.ts index 688f3964..591f1cf7 100644 --- a/projects/keepkey-vault/src/bun/evm-addresses.ts +++ b/projects/keepkey-vault/src/bun/evm-addresses.ts @@ -8,7 +8,7 @@ */ import { EventEmitter } from 'events' import { getSetting, setSetting } from './db' -import type { EvmTrackedAddress, EvmAddressSet } from '../shared/types' +import type { EvmTrackedAddress, EvmAddressSet, EvmAddressChainBalance } from '../shared/types' /** Build an EVM derivation path for a given address index: m/44'/60'/0'/0/{index} */ export function evmAddressPath(index: number): number[] { @@ -152,10 +152,31 @@ export class EvmAddressManager extends EventEmitter { } } + /** Store the selected chain's balance for a specific EVM address. */ + setAddressChainBalance(address: string, chainId: string, balance: EvmAddressChainBalance): void { + const lower = address.toLowerCase() + for (const a of this.addresses) { + if (a.address.toLowerCase() === lower) { + a.chainBalances = { + ...(a.chainBalances || {}), + [chainId]: balance, + } + this.recalculateBalanceUsd(a) + break + } + } + } + /** Reset all address balances to 0 (call before portfolio refresh). */ - resetBalances(): void { + resetBalances(chainId?: string): void { for (const a of this.addresses) { - a.balanceUsd = 0 + if (chainId) { + if (a.chainBalances) delete a.chainBalances[chainId] + this.recalculateBalanceUsd(a) + } else { + a.balanceUsd = 0 + a.chainBalances = {} + } } } @@ -195,7 +216,7 @@ export class EvmAddressManager extends EventEmitter { ) if (hasBalance) { // Add this index permanently - this.addresses.push({ addressIndex: idx, address, balanceUsd: 0 }) + this.addresses.push({ addressIndex: idx, address, balanceUsd: 0, chainBalances: {} }) this.addresses.sort((a, b) => a.addressIndex - b.addressIndex) discovered.push(idx) } @@ -247,11 +268,18 @@ export class EvmAddressManager extends EventEmitter { // Re-check after await if (this.addresses.some(a => a.addressIndex === index)) return - this.addresses.push({ addressIndex: index, address, balanceUsd: 0 }) + this.addresses.push({ addressIndex: index, address, balanceUsd: 0, chainBalances: {} }) // Keep sorted by index this.addresses.sort((a, b) => a.addressIndex - b.addressIndex) } + private recalculateBalanceUsd(address: EvmTrackedAddress): void { + address.balanceUsd = Object.values(address.chainBalances || {}).reduce( + (sum, b) => sum + (Number(b.balanceUsd) || 0), + 0, + ) + } + private persistIndices(): void { // PRIVACY: If a gate is set and returns false (passphrase wallet session), // skip writing indices to disk — they belong to the hidden wallet. diff --git a/projects/keepkey-vault/src/bun/evm-rpc.ts b/projects/keepkey-vault/src/bun/evm-rpc.ts index d47cf4f1..17bad0f0 100644 --- a/projects/keepkey-vault/src/bun/evm-rpc.ts +++ b/projects/keepkey-vault/src/bun/evm-rpc.ts @@ -63,11 +63,28 @@ export async function getTokenMetadata(rpcUrl: string, contractAddress: string): ethCall(rpcUrl, contractAddress, DECIMALS_SIG), ]) - const symbol = decodeString(symbolHex) || 'UNKNOWN' - const name = decodeString(nameHex) || symbol + // Reject contracts that don't actually implement ERC-20 metadata. The + // previous fallbacks (`'UNKNOWN'`, `18`) made every successful eth_call — + // even ones returning empty data for an EOA or a non-token contract that + // happens to live at this address on this chain — look like a valid token. + // The asset picker's multi-chain probe then surfaced an "UNKNOWN/UNKNOWN" + // row for every chain that didn't error, even when the contract genuinely + // doesn't exist there. Throw on missing data so the caller's per-chain + // catch can drop the hit. + const symbol = decodeString(symbolHex) + if (!symbol) { + throw new Error(`No ERC-20 symbol() at ${contractAddress} — not a token here`) + } + if (!decimalsHex || decimalsHex === '0x') { + throw new Error(`No ERC-20 decimals() at ${contractAddress} — not a token here`) + } const decimals = parseInt(decimalsHex, 16) + if (isNaN(decimals)) { + throw new Error(`Malformed ERC-20 decimals() at ${contractAddress}`) + } + const name = decodeString(nameHex) || symbol - return { symbol, name, decimals: isNaN(decimals) ? 18 : decimals } + return { symbol, name, decimals } } /** Check ERC-20 allowance(owner, spender) via eth_call */ @@ -80,6 +97,15 @@ export async function getErc20Allowance(rpcUrl: string, tokenContract: string, o return BigInt(result || '0x0') } +/** Check ERC-20 balanceOf(owner) via eth_call */ +export async function getErc20Balance(rpcUrl: string, tokenContract: string, owner: string): Promise { + const selector = '70a08231' // balanceOf(address) + const ownerPad = owner.toLowerCase().replace(/^0x/, '').padStart(64, '0') + const data = '0x' + selector + ownerPad + const result = await ethCall(rpcUrl, tokenContract, data) + return BigInt(result || '0x0') +} + /** Get ERC-20 decimals via eth_call. Throws if the call fails or returns empty — caller must handle. */ export async function getErc20Decimals(rpcUrl: string, tokenContract: string): Promise { const result = await ethCall(rpcUrl, tokenContract, '0x313ce567') // decimals() @@ -101,6 +127,43 @@ export async function getEvmGasPrice(rpcUrl: string): Promise { return BigInt(result || '0x0') } +/** EIP-1559 fee data — uses eth_feeHistory to derive maxFeePerGas + priority fee. + * Falls back to null on chains that don't support it; caller uses legacy gasPrice. + * + * Buffer policy: maxFeePerGas = nextBaseFee * 3 + priorityFee, with the priority + * fee floored at 1.5 gwei and sampled at the 60th percentile of recent blocks. + * + * The 3x base-fee multiplier covers ~9 blocks of 12.5% growth (1.125^9 ≈ 2.88), + * vs ~6 blocks at 2x. Important because of a reflexive failure mode: if every + * wallet ships 2x and a wave of txs broadcasts together, the next-block base + * fee jumps and the whole wave becomes non-includable simultaneously — every + * user gets stuck for hours waiting for base fee to come back down. 3x leaves + * enough headroom for the network to absorb its own demand without orphaning + * the txs that triggered it. Real-world incident: 2026-05-11, network base + * fee climbed from ~1.3 to 4.5 gwei within minutes, all 2x-buffer txs stalled. */ +export async function getEvmFeeData(rpcUrl: string): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | null> { + try { + const hist = await ethRpc(rpcUrl, 'eth_feeHistory', ['0x4', 'latest', [60]]) + const baseFees = (hist?.baseFeePerGas || []).map((h: string) => BigInt(h)) + const priorityFees = (hist?.reward || []).map((blk: string[]) => BigInt(blk?.[0] || '0x0')) + if (baseFees.length === 0) return null + // Next-block base fee — last feeHistory entry is the predicted next block. + const nextBaseFee = baseFees[baseFees.length - 1] + // 60th-percentile priority fee, floored at 1.5 gwei. 1.5 gwei is the typical + // ETH-mainnet inclusion tip in normal conditions; 1 gwei was leaving us + // behind faster txs whenever the mempool warmed up. + const sortedPriority = [...priorityFees].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + const p60 = sortedPriority[Math.floor(sortedPriority.length * 0.6)] ?? 0n + const minPriority = BigInt(1_500_000_000) // 1.5 gwei + const maxPriorityFeePerGas = p60 > minPriority ? p60 : minPriority + // 3x next base fee — see policy note above. + const maxFeePerGas = nextBaseFee * 3n + maxPriorityFeePerGas + return { maxFeePerGas, maxPriorityFeePerGas } + } catch { + return null + } +} + export async function getEvmNonce(rpcUrl: string, address: string): Promise { const result = await ethRpc(rpcUrl, 'eth_getTransactionCount', [address, 'latest']) return Number(BigInt(result || '0x0')) @@ -128,6 +191,28 @@ export async function broadcastEvmTx(rpcUrl: string, signedTxHex: string): Promi } /** Poll for tx receipt, returning null if not mined within maxWaitMs */ +/** One-shot receipt check — does NOT wait. Returns null if tx isn't mined yet, + * the receipt {status, gasUsed} once it is. `status: false` means the tx + * reverted on-chain (call exception, allowance failure, etc.) — caller should + * surface this as a swap failure instead of waiting forever for a confirmation + * that already happened. */ +export async function getTxReceiptOnce( + rpcUrl: string, + txHash: string, +): Promise<{ status: boolean; gasUsed: bigint; blockNumber: number } | null> { + try { + const receipt = await ethRpc(rpcUrl, 'eth_getTransactionReceipt', [txHash]) + if (!receipt || receipt.status === undefined) return null + return { + status: receipt.status === '0x1', + gasUsed: BigInt(receipt.gasUsed || '0x0'), + blockNumber: Number(BigInt(receipt.blockNumber || '0x0')), + } + } catch { + return null + } +} + export async function waitForTxReceipt( rpcUrl: string, txHash: string, diff --git a/projects/keepkey-vault/src/bun/evm-token-icons.ts b/projects/keepkey-vault/src/bun/evm-token-icons.ts new file mode 100644 index 00000000..88fc9444 --- /dev/null +++ b/projects/keepkey-vault/src/bun/evm-token-icons.ts @@ -0,0 +1,54 @@ +/** + * Logo resolver for custom EVM tokens via CoinGecko. + * + * `/coins/{platform}/contract/{address}` returns `image.{thumb,small,large}`. + * No API key required (free tier ~10–30 rpm — caller caches the resolved URL + * on the custom-token DB row so we only hit CoinGecko on first add). + * + * Returns `null` for unknown contracts, unsupported chains, network errors, + * and rate-limit responses. The caller falls back to the lettered AssetIcon + * avatar in all those cases. + * + * CoinGecko has the deepest coverage of any free token-metadata service we + * could find (~17k tokens vs TrustWallet's ~10k), so going single-source + * here is a deliberate simplification — adding another fallback would buy + * marginal hit-rate at the cost of another HTTP probe per add. + */ + +// vault chainId → CoinGecko `asset_platforms.id`. Only chains CoinGecko +// indexes — others return null, which the caller handles. +const COINGECKO_PLATFORM: Record = { + ethereum: 'ethereum', + bsc: 'binance-smart-chain', + polygon: 'polygon-pos', + base: 'base', + arbitrum: 'arbitrum-one', + optimism: 'optimistic-ethereum', + avalanche: 'avalanche', + fantom: 'fantom', + gnosis: 'xdai', +} + +const FETCH_TIMEOUT_MS = 5000 + +/** + * Resolve a logo URL for an ERC-20 contract via CoinGecko. + * Returns `null` when the chain isn't indexed, the token isn't known, + * or the request times out / is rate-limited. + */ +export async function resolveTokenIcon(chainId: string, address: string): Promise { + const platform = COINGECKO_PLATFORM[chainId] + if (!platform) return null + const url = `https://api.coingecko.com/api/v3/coins/${platform}/contract/${address.toLowerCase()}` + try { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS) + const r = await fetch(url, { signal: ctrl.signal }) + clearTimeout(t) + if (!r.ok) return null + const j = await r.json() as any + return j?.image?.large || j?.image?.small || j?.image?.thumb || null + } catch { + return null + } +} diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index a3d1732a..33d0b24a 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -18,6 +18,15 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" +/** + * hdwallet returns signature/pubkey fields as `Uint8Array | string` + * depending on transport path. RPC clients want hex, so collapse the + * union here. Used by the message-signing handlers; pre-existing + * tx-signing handlers still inline the same pattern (left untouched). + */ +const bytesToHex = (v: Uint8Array | string): string => + v instanceof Uint8Array ? Buffer.from(v).toString('hex') : v + const LOG_DIR = (process.platform === 'win32' ? process.env.LOCALAPPDATA : (process.env.HOME + "/Library/Application Support")) + "/com.keepkey.vault" const LOG_FILE = LOG_DIR + "/vault-backend.log" try { fs.mkdirSync(LOG_DIR, { recursive: true }) } catch {} @@ -87,35 +96,95 @@ process.on('uncaughtException', (err) => { try { sendFatal('uncaught-exception', err) } catch {} }) process.on('unhandledRejection', (reason) => { + // Suppress noise from pioneer-client's internal 60s timer (customHttpClient) racing + // against our withTimeout(45s). The promise is always handled — this is a Bun timing + // artifact where the rejection registers before the .catch() propagates. + const msg = (reason as any)?.message || String(reason) + if (msg === 'Request timed out') return console.error('[Vault] UNHANDLED REJECTION:', reason) try { sendFatal('unhandled-rejection', reason) } catch {} }) import { EngineController, withTimeout } from "./engine-controller" -import { startRestApi, clearFeaturesCache, type RestApiCallbacks } from "./rest-api" +import { startRestApi, clearFeaturesCache, setUiActive, uiHeartbeat, type RestApiCallbacks } from "./rest-api" +import { parseSolanaTx, SolanaTxParseError, solanaMessageSlice } from "./solana-tx" import { AuthStore } from "./auth" -import { getPioneer, getPioneerApiBase, resetPioneer } from "./pioneer" +import { getPioneer, getPioneerApiBase, resetPioneer, DEFAULT_API_BASE } from "./pioneer" +import { rebuildActivityHistory } from "./activity-history" import { buildTx, broadcastTx } from "./txbuilder" import { buildCosmosStakingTx } from "./txbuilder/cosmos" -import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, sendShielded } from "./txbuilder/zcash-shielded" -import { isSidecarReady, startSidecar, stopSidecar, hasFvkLoaded, getCachedFvk, setCachedFvk, onScanProgress, getScanState, updateSyncedTo } from "./zcash-sidecar" +import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, sendShielded, ensureFvkLoaded, displayOrchardAddressOnDevice } from "./txbuilder/zcash-shielded" +import { isSidecarReady, startSidecar, stopSidecar, wipeSidecarWalletDb, hasFvkLoaded, getCachedFvk, onScanProgress, getScanState, updateSyncedTo } from "./zcash-sidecar" import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chains" import { versionCompare } from "../shared/firmware-versions" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" import { EvmAddressManager, evmAddressPath } from "./evm-addresses" import { WalletConnectManager } from "./walletconnect" -import { initDb, factoryResetDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, updateCachedBalance, clearBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getSwapHistoryByTxid, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, apiLogTxidExists, updateApiLogTxMeta, getPioneerServers, addPioneerServerDb, removePioneerServerDb } from "./db" +import { initDb, factoryResetDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, setCustomTokenIcon as dbSetCustomTokenIcon, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, updateCachedBalance, clearBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getSwapHistoryByTxid, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, getPioneerServers, addPioneerServerDb, removePioneerServerDb } from "./db" import { generateReport, reportToPdfBuffer, reportToCsv } from "./reports" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" import * as path from "path" import { EVM_RPC_URLS, getTokenMetadata, broadcastEvmTx } from "./evm-rpc" -import type { ChainBalance, TokenBalance, CustomToken, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, EvmAddressSet, Bip85SeedMeta, StakingPosition } from "../shared/types" +import type { ChainBalance, TokenBalance, CustomToken, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, EvmAddressSet, Bip85SeedMeta, StakingPosition, SwapAsset } from "../shared/types" import type { VaultRPCSchema } from "../shared/rpc-schema" // L3 fix: withTimeout imported from engine-controller (was duplicated here) const PIONEER_TIMEOUT_MS = 60_000 +const PIONEER_PORTFOLIO_CHUNK_SIZE = 8 +const PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS = 45_000 +const PIONEER_PORTFOLIO_MAX_CONCURRENCY = 4 +const PIONEER_PORTFOLIO_TOTAL_TIMEOUT_MS = 120_000 + +function getPioneerPortfolioErrorMessage(err: any): string { + const fields = err?.response?.body?.fields || err?.responseError?.fields + const extraContractsMessage = fields?.['body.extraContracts']?.message + const responseMessage = err?.response?.body?.message || err?.responseError?.message + const responseText = typeof err?.response?.text === 'string' + ? err.response.text + : typeof err?.response?.data === 'string' + ? err.response.data + : '' + return extraContractsMessage || responseMessage || responseText || err?.message || String(err) +} + +function isExtraContractsSchemaError(err: any): boolean { + const msg = getPioneerPortfolioErrorMessage(err) + const responseText = typeof err?.response?.text === 'string' ? err.response.text : '' + const haystack = `${msg} ${responseText}`.toLowerCase() + return haystack.includes('extracontracts') && (haystack.includes('excess property') || haystack.includes('not allowed')) +} + +function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = [] + for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)) + return chunks +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length) + let nextIndex = 0 + const workerCount = Math.min(Math.max(1, concurrency), items.length) + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex++ + if (index >= items.length) return + results[index] = await fn(items[index], index) + } + }) + await Promise.all(workers) + return results +} + +function unwrapPortfolioEntries(resp: any): any[] { + const rawData = resp?.data?.data || resp?.data || {} + return rawData.balances || (Array.isArray(rawData) ? rawData : []) +} // ── Desktop update — open GitHub releases page ── // In-app auto-update is unreliable on both platforms: @@ -260,6 +329,22 @@ const engine = new EngineController() const btcAccounts = new BtcAccountManager() const evmAddresses = new EvmAddressManager() +function attachSigningPolicySnapshot(info: SigningRequestInfo): SigningRequestInfo { + const features = engine.getCachedFeaturesSnapshot() + if (features) { + const policies: any[] = features.policiesList || features.policies || [] + const advPol = policies.find((p: any) => (p.policyName || p.policy_name) === 'AdvancedMode') + if (advPol) info.advancedModeEnabled = !!advPol.enabled + if (!info.firmwareVersion && features.majorVersion) { + info.firmwareVersion = `${features.majorVersion}.${features.minorVersion}.${features.patchVersion}` + } + } + if (!info.firmwareVersion) { + info.firmwareVersion = engine.getDeviceState().firmwareVersion + } + return info +} + // PRIVACY: Wire persistence gate — prevents hidden-wallet EVM indices // from being read/written to disk during passphrase sessions. evmAddresses.canPersist = () => !engine.isPassphraseWallet @@ -278,6 +363,28 @@ function deferredInit() { if (stored.length) console.log(`[Vault] Loaded ${stored.length} custom chains from DB`) } catch {} perf('db + chains loaded') + + // Settings + tracker init MUST run after initDb so swapsEnabled reflects + // the persisted flag. Without this, tracker won't rehydrate pending swaps + // from history on cold boot — they'd stall until executeSwap lazy-init kicks in. + loadSettings() + if (swapsEnabled) { + 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) + } + }, { getDeviceId: () => getWalletDbScope()?.deviceId, getWalletId: () => getWalletDbScope()?.walletId }) + }).catch((e) => { + console.error('[swap-tracker] Failed to initialize swap tracker (swaps will be unavailable):', e.message || e) + }) + } else { + console.log('[swap-tracker] Swap feature flag is OFF — tracker not initialized') + } } /** All chains: built-in + user-added custom chains */ @@ -301,9 +408,19 @@ const auth = new AuthStore() // Settings loaded lazily after DB init — defaults used until then let restApiEnabled = false let walletConnectEnabled = false -let swapsEnabled = false +let swapsEnabled = false // default off; only enabled when explicitly set to '1' let bip85Enabled = false let zcashPrivacyEnabled = false +// True after the per-session incremental scan has caught the wallet up to +// chain tip. The `verified` field on `zcashShieldedStatus` reports this so +// API clients (and any future UI gating) get an honest answer about whether +// validation has actually completed. +let zcashVerifiedThisSession = false +// True while the background scan is in flight. Separate flag so concurrent +// status polls don't each kick their own scan, but the public `verified` +// field above doesn't lie about completion. Cleared after the scan resolves +// (whether successfully or not). +let zcashBackgroundVerifyInFlight = false let emulatorEnabled = false let preReleaseUpdates = false let alphaFirmware = false @@ -311,7 +428,7 @@ let alphaFirmware = false function loadSettings() { restApiEnabled = getSetting('rest_api_enabled') === '1' walletConnectEnabled = getSetting('walletconnect_enabled') === '1' - swapsEnabled = getSetting('swaps_enabled') === '1' + swapsEnabled = getSetting('swaps_enabled') === '1' // absent → false (opt-in model) bip85Enabled = getSetting('bip85_enabled') === '1' zcashPrivacyEnabled = getSetting('zcash_privacy_enabled') === '1' emulatorEnabled = getSetting('emulator_enabled') === '1' @@ -333,6 +450,77 @@ let appVersionCache = '' let restServer: ReturnType | null = null // WalletConnect manager — lazily initialized when user pairs let wcManager: WalletConnectManager | null = null + +// SwapDialog UI state mirror — published fire-and-forget by the WebView on +// each meaningful state change so REST callers (and other Bun internals) can +// observe what the user sees. Cleared when the dialog closes. +let swapUiState: import('../shared/types').SwapUiState = { + phase: 'closed', + fromAsset: null, + toAsset: null, + amount: '', + fiatAmount: '', + inputMode: 'crypto', + isMax: false, + slippageBps: 100, + fromAddress: '', + toAddress: '', + useCustomAddress: false, + customToAddress: '', + quote: null, + previewBuild: null, + error: null, + txid: null, + trackingStatus: null, + confirmations: 0, + outboundConfirmations: undefined, + outboundRequiredConfirmations: undefined, + outboundTxid: null, + relayRequestId: null, + refundReason: null, +} +let swapUiUpdatedAt = 0 +export function getSwapUiState(): { state: import('../shared/types').SwapUiState; updatedAt: number } { + return { state: swapUiState, updatedAt: swapUiUpdatedAt } +} +// Force the cached snapshot back to a clean 'closed' state. Called by REST +// `/api/v2/swap/close` so a stale 'submitted' snapshot from a prior failed +// swap doesn't survive into the next REST-driven session if no SwapDialog +// instance happens to be mounted (and therefore no unmount publishes 'closed'). +export function resetSwapUiState(): void { + swapUiState = { + phase: 'closed', + fromAsset: null, toAsset: null, + amount: '', fiatAmount: '', + inputMode: 'crypto', isMax: false, slippageBps: 100, + fromAddress: '', toAddress: '', + useCustomAddress: false, customToAddress: '', + quote: null, previewBuild: null, error: null, txid: null, + trackingStatus: null, confirmations: 0, + outboundConfirmations: undefined, outboundRequiredConfirmations: undefined, + outboundTxid: null, relayRequestId: null, refundReason: null, + } + swapUiUpdatedAt = Date.now() +} + +// Refcounted setAlwaysOnTop. Multiple sources (WC pair approval, signing +// approval, device pairing approval) can independently want the window +// elevated; using the raw API per-event drops the window prematurely when +// any one source dismisses while another is still pending. +let _alwaysOnTopRefs = 0 +function acquireWindowFocus() { + _alwaysOnTopRefs++ + if (_alwaysOnTopRefs === 1) { + try { mainWindow.setAlwaysOnTop(true); mainWindow.focus() } catch { /* window not ready */ } + } +} +function releaseWindowFocus() { + if (_alwaysOnTopRefs === 0) return // defensive: never go negative + _alwaysOnTopRefs-- + if (_alwaysOnTopRefs === 0) { + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + } +} function getOrCreateWcManager(): WalletConnectManager { if (wcManager) return wcManager wcManager = new WalletConnectManager({ @@ -340,19 +528,170 @@ function getOrCreateWcManager(): WalletConnectManager { const sel = evmAddresses.getSelectedAddress() return sel ? { address: sel.address, addressIndex: sel.addressIndex } : null }, + ensureEvmAddressInfo: async () => { + if (!engine.wallet) return null + if (!evmAddresses.isInitialized) { + try { await evmAddresses.initialize(engine.wallet) } + catch (e: any) { console.warn('[WC] EVM init failed:', e.message); return null } + } + const sel = evmAddresses.getSelectedAddress() + return sel ? { address: sel.address, addressIndex: sel.addressIndex } : null + }, ethSignTx: (params) => { if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignTx(params) }, ethSignMessage: (params) => { if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignMessage(params) }, ethSignTypedData: (params) => { if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignTypedData(params) }, + getCosmosAccountInfo: async (caipChain) => { + if (!engine.wallet) return null + // Only cosmoshub-4 supported in v1; THOR/Maya/Osmosis use the cosmos + // namespace too but need different signers and bech32 prefixes — follow-up. + if (caipChain !== 'cosmos:cosmoshub-4') return null + const addressNList = [0x8000002C, 0x80000076, 0x80000000, 0, 0] // m/44'/118'/0'/0/0 + try { + const addrResult = await engine.wallet.cosmosGetAddress({ addressNList, showDisplay: false }) + const address = typeof addrResult === 'string' ? addrResult : addrResult?.address + if (!address) return null + // Derive raw 33-byte compressed pubkey from BIP32 xpub via ethers HDNode. + const pubkeys = await engine.wallet.getPublicKeys([{ addressNList, curve: 'secp256k1', coin: 'Atom' }]) + const xpub = pubkeys?.[0]?.xpub + if (!xpub) return null + const { ethers } = await import('ethers') + const node = ethers.utils.HDNode.fromExtendedKey(xpub) + const pubkeyHex = node.publicKey.replace(/^0x/, '') + const pubkeyBase64 = Buffer.from(pubkeyHex, 'hex').toString('base64') + return { address, pubkeyBase64, addressNList } + } catch (e: any) { + console.warn('[WC] getCosmosAccountInfo failed:', e.message) + return null + } + }, + cosmosSignAmino: async ({ addressNList, signDoc }) => { + if (!engine.wallet) throw new Error('Device disconnected') + // Translate WC StdSignDoc → hdwallet CosmosSignTx. + // StdSignDoc: { chain_id, account_number, sequence, fee, msgs, memo } + // hdwallet: { addressNList, tx: { msg, fee, signatures, memo }, chain_id, account_number, sequence } + const result = await engine.wallet.cosmosSignTx({ + addressNList, + tx: { + msg: signDoc.msgs ?? [], + fee: signDoc.fee, + signatures: [], + memo: signDoc.memo ?? '', + }, + chain_id: signDoc.chain_id, + account_number: String(signDoc.account_number ?? '0'), + sequence: String(signDoc.sequence ?? '0'), + }) + const sig = result?.signatures?.[0] + if (!sig) throw new Error('Device returned no signature') + // hdwallet may return hex; WC requires base64. + const sigStripped = sig.startsWith('0x') ? sig.slice(2) : sig + const signatureBase64 = /^[0-9a-f]+$/i.test(sigStripped) + ? Buffer.from(sigStripped, 'hex').toString('base64') + : sigStripped + return { signatureBase64 } + }, + getSolanaAccountInfo: async (caipChain) => { + if (!engine.wallet) return null + if (caipChain !== 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') return null + // Solana uses ed25519 with a 4-element fully hardened path m/44'/501'/0'/0' + // (NOT extended to a 5th index — see rest-api.ts:2441). + const addressNList = [0x8000002C, 0x800001F5, 0x80000000, 0x80000000] + try { + const r = await engine.wallet.solanaGetAddress({ addressNList, showDisplay: false }) + const address = typeof r === 'string' ? r : (r as any)?.address + if (!address) return null + return { address, addressNList } + } catch (e: any) { + console.warn('[WC] getSolanaAccountInfo failed:', e.message) + return null + } + }, + solanaSignMessageRaw: async ({ addressNList, messageBase58 }) => { + if (!engine.wallet) throw new Error('Device disconnected') + const bs58 = (await import('bs58')).default + const messageBytes = Buffer.from(bs58.decode(messageBase58)) + const result = await engine.wallet.solanaSignMessage({ + addressNList, + message: messageBytes, + showDisplay: true, + }) + const sig = result?.signature + if (!sig) throw new Error('Device returned no signature') + const sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') + return { signatureBase64: Buffer.from(sigBytes).toString('base64') } + }, + broadcastViaPioneer: async ({ networkId, serialized }) => { + const pioneer = await getPioneer() + const resp = await pioneer.Broadcast({ networkId, serialized }) + const data = resp?.data ?? resp + const txid = data?.txid || data?.tx_hash || data?.hash + if (!txid) throw new Error(`Broadcast failed: ${JSON.stringify(data).slice(0, 200)}`) + return String(txid) + }, + solanaSignTransactionRaw: async ({ addressNList, signerAddress, transactionBase64 }) => { + if (!engine.wallet) throw new Error('Device disconnected') + const { parseSolanaTx, solanaMessageSlice, parseSolanaMessage } = await import('./solana-tx') + const bs58 = (await import('bs58')).default + const fullTx = Buffer.from(transactionBase64, 'base64') + const parsed = parseSolanaTx(fullTx) + const messageBytes = solanaMessageSlice(fullTx, parsed) + + // Find which signer slot belongs to our account. Required signers are + // the first `numRequiredSignatures` entries of `staticAccounts`. If our + // pubkey isn't among them, this tx isn't ours to sign and writing to + // any slot would produce an invalid signed transaction. + const message = parseSolanaMessage(messageBytes) + const ourPubkey = bs58.decode(signerAddress) + if (ourPubkey.length !== 32) { + throw new Error(`Invalid signer address: bs58-decoded length ${ourPubkey.length} (expected 32)`) + } + let signerIdx = -1 + for (let i = 0; i < message.header.numRequiredSignatures; i++) { + const acct = message.staticAccounts[i] + if (acct && acct.length === ourPubkey.length && Buffer.from(acct).equals(Buffer.from(ourPubkey))) { + signerIdx = i + break + } + } + if (signerIdx < 0) { + throw new Error(`Wallet account ${signerAddress} is not a required signer for this transaction`) + } + + let sigBytes: Uint8Array + if (parsed.isVersioned) { + const msgRes = await engine.wallet.solanaSignMessage({ addressNList, message: messageBytes, showDisplay: true }) + const sig = msgRes?.signature + if (!sig) throw new Error('Device returned no signature for v0 tx') + sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') + } else { + const result = await engine.wallet.solanaSignTx({ + addressNList, + rawTx: Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64'), + }) + if (!result?.signature) throw new Error('Device returned no signature for legacy tx') + sigBytes = result.signature instanceof Uint8Array ? result.signature : Buffer.from(result.signature, 'base64') + } + if (sigBytes.length !== 64) throw new Error(`Unexpected signature length ${sigBytes.length}`) + + const slotOffset = parsed.sigStart + signerIdx * 64 + if (fullTx.length < slotOffset + 64) { + throw new Error('Raw tx too short to hold our signer slot') + } + const out = Buffer.from(fullTx) + for (let i = 0; i < 64; i++) out[slotOffset + i] = sigBytes[i] + return { + transactionBase64: out.toString('base64'), + signatureBase64: Buffer.from(sigBytes).toString('base64'), + } + }, requestSigningApproval: async (info) => { + attachSigningPolicySnapshot(info) try { rpc.send['signing-request'](info) } catch { /* webview not ready */ } - try { - mainWindow.setAlwaysOnTop(true) - mainWindow.focus() - } catch { /* window not ready */ } + acquireWindowFocus() try { return await auth.requestSigningApproval(info.id) } finally { - try { mainWindow.setAlwaysOnTop(false) } catch {} + releaseWindowFocus() } }, dismissSigning: (id) => { @@ -362,6 +701,14 @@ function getOrCreateWcManager(): WalletConnectManager { onSessionsChanged: (sessions) => { try { rpc.send['wc-sessions'](sessions) } catch {} }, + onPairApprovalRequest: (info) => { + try { rpc.send['wc-pair-request'](info) } catch {} + acquireWindowFocus() + }, + onPairApprovalDismiss: (id) => { + try { rpc.send['wc-pair-dismiss']({ id }) } catch {} + releaseWindowFocus() + }, }) return wcManager } @@ -388,27 +735,49 @@ function getAppSettings() { } } +function getWalletDbScope(): { deviceId: string; walletId: string } | null { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) return null + const seedId = engine.currentSeedEthAddress?.toLowerCase() + if (!seedId) return null + return { deviceId, walletId: `${deviceId}:${seedId}` } +} + +const pendingScopedApiLogs: ApiLogEntry[] = [] + +function flushPendingScopedApiLogs() { + const scope = getWalletDbScope() + if (!scope || engine.isPassphraseWallet || pendingScopedApiLogs.length === 0) return + const pending = pendingScopedApiLogs.splice(0) + for (const entry of pending) { + const scopedEntry = { ...entry, ...scope } + try { insertApiLog(scopedEntry) } catch { /* db not ready */ } + try { rpc.send['api-log'](scopedEntry) } catch { /* webview not ready */ } + } +} + // Callbacks bridge REST → RPC UI const restCallbacks: RestApiCallbacks = { onApiLog: (entry: ApiLogEntry) => { - try { rpc.send['api-log'](entry) } catch { /* webview not ready */ } + const scope = getWalletDbScope() + const scopedEntry = scope ? { ...entry, ...scope } : entry + try { rpc.send['api-log'](scopedEntry) } catch { /* webview not ready */ } // PRIVACY: Don't persist API activity from passphrase wallets to disk. - if (!engine.isPassphraseWallet) { - try { insertApiLog(entry) } catch { /* db not ready */ } + if (!engine.isPassphraseWallet && scope) { + try { insertApiLog(scopedEntry) } catch { /* db not ready */ } + } else if (!engine.isPassphraseWallet && !scope) { + pendingScopedApiLogs.push(entry) + if (pendingScopedApiLogs.length > 100) pendingScopedApiLogs.shift() } }, onSigningRequest: async (info: SigningRequestInfo) => { + attachSigningPolicySnapshot(info) try { rpc.send['signing-request'](info) } catch { /* webview not ready */ } - // Bring window to front so user sees the approval prompt immediately - try { - mainWindow.setAlwaysOnTop(true) - mainWindow.focus() - } catch { /* window not ready */ } + acquireWindowFocus() try { return await auth.requestSigningApproval(info.id) } finally { - // Restore normal window level after user responds (or timeout) - try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + releaseWindowFocus() } }, onSigningDismissed: (id: string) => { @@ -416,19 +785,20 @@ const restCallbacks: RestApiCallbacks = { }, onPairRequest: (info) => { try { rpc.send['pair-request'](info) } catch { /* webview not ready */ } - // Bring window to front so user sees the pairing approval prompt - try { - mainWindow.setAlwaysOnTop(true) - mainWindow.focus() - } catch { /* window not ready */ } + acquireWindowFocus() }, onPairDismissed: () => { - // Restore normal window level + dismiss frontend overlay (covers timeout case) - try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + releaseWindowFocus() try { rpc.send['pair-dismissed']({}) } catch { /* webview not ready */ } }, getVersion: () => appVersionCache, emuSigningOp: (fn, details) => emuSigningOp(fn, details), + getSwapUiState: () => getSwapUiState(), + sendSwapCmd: (cmd) => { + try { rpc.send['swap-cmd'](cmd) } catch { /* webview not ready */ } + }, + getPioneer: () => getPioneer(), + getPioneerApiBase: () => getPioneerApiBase(), } /** Check if a port is already in use by trying to connect to it */ @@ -536,6 +906,87 @@ async function emuSigningOp( return emuInteractiveConfirm(fn, details, engine.emuDelegate) } +// Race engine.getEmulatorMnemonic() against a 3s deadline. The DebugLink +// read can hang on the dylib path (documented in emu-7.15-debugging.md), +// and a hung verify must NOT block create/import/loadDevice forever — but +// a timeout is a verification failure, not silently OK, since shipping a +// wallet without confirming the firmware really holds the seed leads to +// users backing up unrecoverable phrases. +async function raceVerifyMnemonic(expected: string): Promise<{ ok: true } | { ok: false; reason: string }> { + return Promise.race<{ ok: true } | { ok: false; reason: string }>([ + engine.getEmulatorMnemonic() + .then(actual => { + if (!actual) return { ok: false, reason: 'firmware returned no mnemonic via DebugLink' } + if (actual.trim() !== expected.trim()) { + return { ok: false, reason: 'firmware mnemonic does not match expected seed' } + } + return { ok: true } + }) + .catch(err => ({ ok: false, reason: `verify error: ${err?.message || err}` })), + new Promise<{ ok: false; reason: string }>(resolve => + setTimeout(() => resolve({ ok: false, reason: 'verify timed out after 3s' }), 3000) + ), + ]) +} + +/** + * Run a fresh Orchard scan before any Zcash send/shield/deshield. The sidecar's + * note set is whatever was true at `synced_to`; if that's behind the chain tip, + * an "unspent" note may already be nullified on-chain and the broadcast will + * be rejected with `orchard double-spend: duplicate nullifier` after the user + * has already approved on the device. Calling scan first costs ~tens of ms + * when at tip and a few seconds when behind — strictly better than burning a + * device confirm + Halo2 proof on a doomed tx. + * + * Failure here is fatal — we'd rather surface "scan failed" than silently + * proceed with stale data. + */ +async function ensureZcashScanFresh(): Promise { + try { + // Always incremental — picks up blocks since synced_to. Cheap (<1s when + // at tip, a few seconds when behind). NEVER trigger full rescan from + // here: a fresh wallet's first scan from release block is one thing, + // but a months-old wallet would take hours and lock the user out of + // every send. Full rescan must be a deliberate user action. + const result = await scanOrchardNotes() + if (result?.synced_to != null) updateSyncedTo(result.synced_to) + zcashVerifiedThisSession = true + console.log( + `[zcash-presend] Incremental scan complete: synced_to=${result?.synced_to ?? '?'}, ` + + `new_notes=${result?.notes_found ?? 0}`, + ) + } catch (e: any) { + throw new Error(`Pre-send chain scan failed: ${e?.message || e}. Retry after the network is reachable.`) + } +} + +/** + * Background incremental scan kicked off once per session on first Privacy tab + * access. Catches the wallet up to chain tip from `synced_to` — typically a + * few seconds even on long-running wallets. Frontend gets `scan-progress` + * events. Failure is silently logged; the next send-time scan will retry. + * + * Does NOT do a full rescan. Full rescans take hours on real wallets and + * must be a deliberate user action (the manual "Repair wallet" / "Full scan" + * controls in the UI). + */ +function maybeStartBackgroundWalletVerification(): void { + if (zcashVerifiedThisSession || zcashBackgroundVerifyInFlight || !hasFvkLoaded()) return + zcashBackgroundVerifyInFlight = true + ;(async () => { + try { + const result = await scanOrchardNotes() + if (result?.synced_to != null) updateSyncedTo(result.synced_to) + zcashVerifiedThisSession = true + console.log(`[zcash] Background scan caught up: synced_to=${result?.synced_to ?? '?'}, new_notes=${result?.notes_found ?? 0}`) + } catch (e: any) { + console.warn('[zcash] Background scan failed (non-fatal):', e?.message || e) + } finally { + zcashBackgroundVerifyInFlight = false + } + })() +} + // ── RPC Bridge (Electrobun UI ↔ Bun) ───────────────────────────────── const rpc = BrowserView.defineRPC({ maxRequestTime: 1_800_000, // 30 minutes — generous for device-interactive ops, but not infinite @@ -563,55 +1014,77 @@ const rpc = BrowserView.defineRPC({ recoverDevice: async (params) => { await engine.recoverDevice(params) }, loadDevice: async (params) => { if (engine.isEmulator) { + const { saveMnemonic, deleteMnemonic } = await import('./emulator-keychain') + const { getActiveFlashName, deleteFlash, stopEmulator } = await import('./emulator') + const { deleteEmulatorWalletMeta, deleteDeviceSnapshot } = await import('./db') + const flashName = getActiveFlashName() + // Save mnemonic FIRST — connectEmulator's auto-reload (stale // storage key recovery) will pick up the NEW seed instead of // the previously saved one. if (params.mnemonic) { - const { saveMnemonic } = await import('./emulator-keychain') - const { getActiveFlashName } = await import('./emulator') - console.log('[Vault] Saving new mnemonic before load (flash=%s)', getActiveFlashName()) - saveMnemonic(getActiveFlashName(), params.mnemonic) + console.log('[Vault] Saving new mnemonic before load (flash=%s)', flashName) + saveMnemonic(flashName, params.mnemonic) } - // Firmware rejects loadDevice on an already-initialized device. - // Wipe first so the new mnemonic actually takes effect. - if (engine.cachedFeatures?.initialized) { - console.log('[Vault] Emulator already initialized — wiping before loadDevice') - await emuConfirmOp(() => engine.wallet!.wipe()) - const { flushRingBuffers } = await import('./emulator') - flushRingBuffers() - // connectEmulator may auto-reload our just-saved mnemonic - // via the stale-storage-key recovery path. - await engine.connectEmulator() - } + try { + // Firmware rejects loadDevice on an already-initialized device. + // Wipe first so the new mnemonic actually takes effect. + if (engine.cachedFeatures?.initialized) { + console.log('[Vault] Emulator already initialized — wiping before loadDevice') + await emuConfirmOp(() => engine.wallet!.wipe()) + const { flushRingBuffers } = await import('./emulator') + flushRingBuffers() + // connectEmulator may auto-reload our just-saved mnemonic + // via the stale-storage-key recovery path. + await engine.connectEmulator() + } - // If auto-reload already initialized the device with the new - // seed, skip the manual loadDevice — firmware would reject it. - if (!engine.cachedFeatures?.initialized) { - await emuConfirmOp(() => engine.loadDevice({ ...params, skipRefresh: true })) - } else { - console.log('[Vault] Device already initialized after reconnect — skipping manual loadDevice') - } + // If auto-reload already initialized the device with the new + // seed, skip the manual loadDevice — firmware would reject it. + if (!engine.cachedFeatures?.initialized) { + await emuConfirmOp(() => engine.loadDevice({ ...params, skipRefresh: true })) + } else { + console.log('[Vault] Device already initialized after reconnect — skipping manual loadDevice') + } - // Drain stale ButtonAck + reconnect for clean transport - const { flushRingBuffers: flush } = await import('./emulator') - flush() - await engine.connectEmulator() + // Drain stale ButtonAck + reconnect for clean transport + const { flushRingBuffers: flush } = await import('./emulator') + flush() + await engine.connectEmulator() - // Verify the firmware actually holds the mnemonic we loaded - if (params.mnemonic) { - const actual = await engine.getEmulatorMnemonic() - if (!actual) { - console.error('[Vault] SEED VERIFY FAIL — firmware returned no mnemonic via DebugLink') - } else if (actual.trim() !== params.mnemonic.trim()) { - console.error('[Vault] SEED VERIFY FAIL — firmware mnemonic does NOT match loaded seed') - console.error('[Vault] expected first word: %s', params.mnemonic.trim().split(/\s+/)[0]) - console.error('[Vault] actual first word: %s', actual.trim().split(/\s+/)[0]) - } else { + // Verify the firmware actually holds the mnemonic we loaded. + // MUST be fatal — same contract as create/import. Wrap in a + // 3s race so a stuck DebugLink doesn't block the RPC, but + // treat the timeout as a verification failure so the wizard + // doesn't silently advance with an unverified wallet. + if (params.mnemonic) { + const expected = params.mnemonic + const verifyResult = await raceVerifyMnemonic(expected) + if (!verifyResult.ok) { + throw new Error(`Seed verification failed — ${verifyResult.reason}`) + } console.log('[Vault] SEED VERIFY OK — firmware mnemonic matches loaded seed') } + return + } catch (err) { + // Rollback the saved mnemonic + persisted metadata so + // connectEmulator's auto-reload can't silently resurrect a + // wallet the wizard reported as failed. + console.error('[Vault] loadDevice failed, rolling back:', (err as Error).message) + const deviceId = engine.cachedFeatures?.deviceId + try { + const { closeEmulatorWindow } = await import('./emulator-window') + closeEmulatorWindow() + } catch {} + try { engine.disconnectEmulator() } catch {} + try { stopEmulator() } catch {} + try { deleteMnemonic(flashName) } catch {} + try { deleteFlash(flashName) } catch {} + try { deleteEmulatorWalletMeta(flashName) } catch {} + if (deviceId) { try { deleteDeviceSnapshot(deviceId) } catch {} } + throw err } - return } await engine.loadDevice(params) }, @@ -745,11 +1218,46 @@ const rpc = BrowserView.defineRPC({ if (!engine.wallet) throw new Error('No device connected') await engine.wallet.applyPolicy({ policyName: params.policyName, enabled: params.enabled }) clearFeaturesCache() + try { + await engine.refreshFeaturesSnapshot() + } catch (e: any) { + engine.invalidateFeaturesSnapshot() + console.warn('[policy] Applied policy but failed to refresh features:', e?.message || e) + } }, ping: async (params) => { if (!engine.wallet) throw new Error('No device connected') return await engine.wallet.ping({ msg: params.msg || 'pong', passphrase: false }) }, + openExternal: async (params) => { + // Validate up front — only open http(s) URLs, never local file:// + // or javascript: schemes. The WebView passes user-visible URLs + // (explorer / docs links) so this is more defense-in-depth than + // hardening against the user. + const url = String(params?.url || "") + if (!/^https?:\/\//i.test(url)) { + throw new Error("openExternal: only http(s) URLs are allowed") + } + const cmd = process.platform === "win32" ? ["cmd", "/c", "start", "", url] + : process.platform === "darwin" ? ["open", url] + : ["xdg-open", url] + try { + Bun.spawn(cmd, { stdio: ["ignore", "ignore", "ignore"] }) + } catch (e: any) { + throw new Error(`Failed to open URL: ${e?.message || e}`) + } + return { ok: true as const } + }, + cancelDeviceSigning: async () => { + // User backed out of an in-flight confirm/PIN/passphrase prompt. + // Sends a Cancel message to the device, which dismisses the on- + // screen prompt and releases the transport lock. The pending + // signing promise inside hdwallet rejects with a "Cancelled" + // error — the swap dialog catches it and resets to 'review'. + if (!engine.wallet) return { ok: false } + await engine.wallet.cancel().catch(() => {}) + return { ok: true } + }, wipeDevice: async () => { if (!engine.wallet) throw new Error('No device connected') // Cancel any pending PIN/passphrase request before wiping — @@ -950,65 +1458,67 @@ const rpc = BrowserView.defineRPC({ console.debug(`[solanaSignTx] RPC call received`) // Pioneer returns full serialized tx: [compact-u16:sigCount][sig0(64)]...[sigN(64)][message] - // Firmware expects just the message bytes for parsing and signing. - // Extract message portion before sending to device. - let deviceParams = params - if (params.rawTx) { - const fullTx = Buffer.from( - typeof params.rawTx === 'string' ? params.rawTx : Buffer.from(params.rawTx).toString('base64'), - 'base64', - ) - // Read compact-u16 signature count - let pos = 0 - let sigCount = 0 - if (fullTx[0] < 0x80) { - sigCount = fullTx[0]; pos = 1 - } else if (fullTx.length >= 2 && fullTx[1] < 0x80) { - sigCount = (fullTx[0] & 0x7f) | (fullTx[1] << 7); pos = 2 - } else if (fullTx.length >= 3) { - sigCount = (fullTx[0] & 0x7f) | ((fullTx[1] & 0x7f) << 7) | (fullTx[2] << 14); pos = 3 - } - // Solana transactions have at most ~20 signers; reject clearly malformed data - if (sigCount > 127) { - throw new Error(`[solanaSignTx] Unreasonable signature count (${sigCount}) — malformed transaction`) - } - const messageStart = pos + sigCount * 64 - console.debug(`[solanaSignTx] fullTx=${fullTx.length}B sigCount=${sigCount} messageStart=${messageStart}`) - if (sigCount > 0 && messageStart < fullTx.length) { - const messageBytes = fullTx.subarray(messageStart) - deviceParams = { ...params, rawTx: Buffer.from(messageBytes).toString('base64') } - console.debug(`[solanaSignTx] Extracted message: ${messageBytes.length}B (stripped ${sigCount} dummy sigs)`) - } + // See solana-tx.ts for the wire-format contract + malformed-input rejection rules. + if (!params.rawTx) { + throw new Error('[solanaSignTx] rawTx is required') + } + const fullTx = Buffer.from( + typeof params.rawTx === 'string' ? params.rawTx : Buffer.from(params.rawTx).toString('base64'), + 'base64', + ) + let parsed + try { + parsed = parseSolanaTx(fullTx) + } catch (err) { + if (err instanceof SolanaTxParseError) throw new Error(`[solanaSignTx] ${err.message}`) + throw err } - console.debug(`[solanaSignTx] Calling hdwallet.solanaSignTx`) - const result = engine.isEmulator - ? await emuSigningOp(() => engine.wallet!.solanaSignTx(deviceParams), { operation: 'solanaSignTx', chain: 'Solana' }) - : await engine.wallet.solanaSignTx(deviceParams) - - console.debug(`[solanaSignTx] hdwallet result: hasSig=${!!result?.signature} sigLen=${result?.signature?.length || 0}`) - - // Assemble signed tx: replace the 64-byte dummy signature in rawTx with real signature - if (result?.signature && params.rawTx) { - const rawBytes = Buffer.from( - typeof params.rawTx === 'string' ? params.rawTx : Buffer.from(params.rawTx).toString('base64'), - 'base64', - ) - const sigBytes = result.signature instanceof Uint8Array + // KeepKey firmware message type 752 (SolanaSignTx) parses legacy + // messages only. Versioned (v0) messages are signed via type + // 754 (SolanaSignMessage) over the exact message bytes — the + // 0x80 prefix and v0 payload are preserved, producing an + // Ed25519 signature valid for the original v0 transaction. + // The device shows a generic "sign message" prompt; users + // review the parsed tx in the Vault approval dialog. + let sigBytes: Uint8Array + if (parsed.isVersioned) { + const messageBytes = solanaMessageSlice(fullTx, parsed) + console.debug(`[solanaSignTx] v0 tx detected — routing through solanaSignMessage (${messageBytes.length}B message incl. 0x80 prefix)`) + const msgRes = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.solanaSignMessage({ addressNList: params.addressNList, message: messageBytes, showDisplay: true }), { operation: 'solanaSignMessage', chain: 'Solana' }) + : await engine.wallet.solanaSignMessage({ addressNList: params.addressNList, message: messageBytes, showDisplay: true }) + const sig = msgRes?.signature + if (!sig) throw new Error('[solanaSignTx] v0: device returned no signature') + sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') + } else { + const deviceParams = { + ...params, + rawTx: Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64'), + } + console.debug(`[solanaSignTx] legacy — fullTx=${fullTx.length}B sigCount=${parsed.sigCount} messageStart=${parsed.messageStart}`) + const result = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.solanaSignTx(deviceParams), { operation: 'solanaSignTx', chain: 'Solana' }) + : await engine.wallet.solanaSignTx(deviceParams) + if (!result?.signature) return result + sigBytes = result.signature instanceof Uint8Array ? result.signature : Buffer.from(result.signature, 'base64') - // Full tx format: [1 byte sig_count] [64 bytes dummy sig] [message...] - // Replace bytes 1-64 with real signature - if (rawBytes.length > 65 && sigBytes.length === 64) { - sigBytes.forEach((b: number, i: number) => { rawBytes[1 + i] = b }) - const assembled = rawBytes.toString('base64') - console.debug(`[solanaSignTx] Assembled signed tx: ${rawBytes.length}B`) - return { signature: result.signature, serializedTx: assembled } - } else { - console.debug(`[solanaSignTx] Cannot assemble: rawBytes=${rawBytes.length}B sigBytes=${sigBytes.length}B`) - } } - return result + + // Assemble signed tx: write sig into the first sig slot + // (starts at `parsed.sigStart`, 64 bytes). + if (sigBytes.length !== 64) { + throw new Error(`[solanaSignTx] Unexpected signature length ${sigBytes.length}`) + } + const rawBytes = Buffer.from(fullTx) + if (rawBytes.length < parsed.sigStart + 64) { + throw new Error('[solanaSignTx] Raw tx too short to hold signature') + } + for (let i = 0; i < 64; i++) rawBytes[parsed.sigStart + i] = sigBytes[i] + const assembled = rawBytes.toString('base64') + console.debug(`[solanaSignTx] Assembled signed tx: ${rawBytes.length}B (versioned=${parsed.isVersioned})`) + return { signature: sigBytes, serializedTx: assembled } }, solanaSignMessage: async (params) => { if (!engine.wallet) throw new Error('No device connected') @@ -1057,15 +1567,64 @@ const rpc = BrowserView.defineRPC({ } }, + // ── TRON TIP-191 personal_sign ──────────────────────────────── + tronSignMessage: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.tronSignMessage(params), { operation: 'tronSignMessage', chain: 'Tron' }) + : await engine.wallet.tronSignMessage(params) + if (!result) throw new Error('tronSignMessage returned no result') + return { address: result.address, signature: bytesToHex(result.signature) } + }, + tronVerifyMessage: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const ok = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.tronVerifyMessage(params), { operation: 'tronVerifyMessage', chain: 'Tron' }) + : await engine.wallet.tronVerifyMessage(params) + return { verified: !!ok } + }, + + // ── TRON TIP-712 typed-data hash mode ───────────────────────── + tronSignTypedHash: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.tronSignTypedHash(params), { operation: 'tronSignTypedHash', chain: 'Tron' }) + : await engine.wallet.tronSignTypedHash(params) + if (!result) throw new Error('tronSignTypedHash returned no result') + return { address: result.address, signature: bytesToHex(result.signature) } + }, + + // ── TON Ed25519 SignMessage (AdvancedMode-gated firmware-side) ─ + tonSignMessage: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.tonSignMessage(params), { operation: 'tonSignMessage', chain: 'TON' }) + : await engine.wallet.tonSignMessage(params) + if (!result) throw new Error('tonSignMessage returned no result') + return { publicKey: bytesToHex(result.publicKey), signature: bytesToHex(result.signature) } + }, + + // ── Solana off-chain message (domain-separated envelope) ───── + solanaSignOffchainMessage: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = engine.isEmulator + ? await emuSigningOp(() => engine.wallet!.solanaSignOffchainMessage(params), { operation: 'solanaSignOffchainMessage', chain: 'Solana' }) + : await engine.wallet.solanaSignOffchainMessage(params) + if (!result) throw new Error('solanaSignOffchainMessage returned no result') + return { publicKey: bytesToHex(result.publicKey), signature: bytesToHex(result.signature) } + }, + // ── Pioneer integration (batch portfolio API) ──────────────── - getBalances: async () => { + getBalances: async ({ forceRefresh = false } = {}) => { if (!engine.wallet) throw new Error('No device connected') // Initialize Pioneer client — isolate failure so device derivation still works let pioneer: any = null + let pioneerInitError: Error | null = null try { pioneer = await getPioneer() } catch (e: any) { + pioneerInitError = e instanceof Error ? e : new Error(e?.message || String(e)) console.warn('[getBalances] Pioneer init failed (will return zero balances):', e.message) // Notify UI so user can change server or get support try { rpc.send['pioneer-error']({ message: e.message, url: getPioneerApiBase() }) } catch { /* webview not ready */ } @@ -1151,8 +1710,10 @@ const rpc = BrowserView.defineRPC({ } // Non-EVM, non-UTXO chains (cosmos, xrp, etc.) — skip hidden chains (e.g. zcash-shielded has dedicated RPC) + console.log(`[getBalances] nonEvmChains to derive: ${nonEvmChains.filter(c => !c.hidden).map(c => c.id).join(', ')}`) for (const chain of nonEvmChains) { if (chain.hidden) continue + const t0 = Date.now() try { const addrParams: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } if (chain.scriptType) addrParams.scriptType = chain.scriptType @@ -1161,16 +1722,18 @@ const rpc = BrowserView.defineRPC({ const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod const result = await wallet[method](addrParams) const address = typeof result === 'string' ? result : result?.address || '' + const ms = Date.now() - t0 if (address) { + console.log(`[getBalances] ${chain.id} address derived in ${ms}ms: ${address.substring(0, 20)}... caip=${chain.caip}`) pubkeys.push({ caip: chain.caip, pubkey: address, chainId: chain.id, symbol: chain.symbol, networkId: chain.networkId }) - if (chain.id === 'tron') console.log(`[getBalances] TRON address derived: ${address}, caip: ${chain.caip}, networkId: ${chain.networkId}`) } else { - if (chain.id === 'tron') console.warn(`[getBalances] TRON address derivation returned empty! result:`, JSON.stringify(result)) + console.warn(`[getBalances] ${chain.id} address empty after ${ms}ms! method=${method} result=${JSON.stringify(result)}`) } } catch (e: any) { - console.warn(`[getBalances] ${chain.coin} address failed:`, e.message) + console.warn(`[getBalances] ${chain.id} address THREW (${Date.now() - t0}ms): ${e.message}`) } } + console.log(`[getBalances] pubkeys after nonEVM derivation: ${pubkeys.length} total (nonEVM added: ${pubkeys.filter(p => !['bitcoin','litecoin','dogecoin','bitcoincash','dash','digibyte','zcash'].includes(p.chainId) && !p.chainId.startsWith('evm')).length})`) // 3. Add ALL BTC xpubs from multi-account manager const btcChain = allChains.find(c => c.id === 'bitcoin')! @@ -1195,7 +1758,7 @@ const rpc = BrowserView.defineRPC({ pubkeys.push({ caip: entry.caip, pubkey: entry.pubkey, chainId: 'bitcoin', symbol: 'BTC', networkId: btcChain.networkId }) } - console.log(`[getBalances] ${pubkeys.length} pubkeys (${btcPubkeyEntries.length} BTC xpubs) → single GetPortfolioBalances call`) + console.log(`[getBalances] ${pubkeys.length} pubkeys (${btcPubkeyEntries.length} BTC xpubs) → chunked GetPortfolioBalances calls`) // Build networkId → chainId lookup for token grouping (lowercase keys — Pioneer may return different casing) const networkToChain = new Map() @@ -1206,21 +1769,90 @@ const rpc = BrowserView.defineRPC({ networkToChain.set(chain.networkId.toLowerCase(), chain.id) } - // 3. Single API call — GetPortfolioBalances returns natives + tokens in one flat array + // 3. Chunked API calls — GetPortfolioBalances returns natives + tokens in one flat array const results: ChainBalance[] = [] try { - if (!pioneer) throw new Error('Pioneer client not available') - const resp = await withTimeout( - pioneer.GetPortfolioBalances( - { pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) }, - { forceRefresh: true } - ), - PIONEER_TIMEOUT_MS, - 'GetPortfolioBalances' + if (!pioneer) throw (pioneerInitError || new Error('Pioneer client not available')) + const extraContracts = getCustomTokens().map(ct => ({ + networkId: ct.networkId, + contractAddress: ct.contractAddress, + decimals: ct.decimals, + symbol: ct.symbol, + name: ct.name, + icon: ct.iconUrl, + })) + const pubkeyChunks = chunkArray(pubkeys, PIONEER_PORTFOLIO_CHUNK_SIZE) + const chunkResults = await withTimeout( + mapWithConcurrency(pubkeyChunks, PIONEER_PORTFOLIO_MAX_CONCURRENCY, async (chunk, i) => { + const chunkBody: any = { pubkeys: chunk.map(p => ({ caip: p.caip, pubkey: p.pubkey })) } + if (extraContracts.length > 0) chunkBody.extraContracts = extraContracts + try { + let resp: any + try { + resp = await withTimeout( + pioneer.GetPortfolioBalances(chunkBody, { forceRefresh }), + PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS, + `GetPortfolioBalances chunk ${i + 1}/${pubkeyChunks.length}` + ) + } catch (err: any) { + if (!extraContracts.length || !isExtraContractsSchemaError(err)) throw err + console.warn('[getBalances] Pioneer rejected extraContracts; retrying portfolio chunk without custom tokens') + resp = await withTimeout( + pioneer.GetPortfolioBalances( + { pubkeys: chunkBody.pubkeys }, + { forceRefresh } + ), + PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS, + `GetPortfolioBalances chunk ${i + 1}/${pubkeyChunks.length}` + ) + } + return { entries: unwrapPortfolioEntries(resp), error: null as string | null } + } catch (err: any) { + const sampleChains = chunk.map((p: any) => String(p.caip || '').split('/')[0]).join(', ') + const error = getPioneerPortfolioErrorMessage(err) + console.warn(`[getBalances] Portfolio chunk ${i + 1}/${pubkeyChunks.length} failed (${sampleChains}):`, error) + return { entries: [] as any[], error } + } + }), + PIONEER_PORTFOLIO_TOTAL_TIMEOUT_MS, + 'GetPortfolioBalances chunks' ) - // Unwrap: { data: { balances: [...] } } or { data: [...] } - const rawData = resp?.data?.data || resp?.data || {} - const allEntries: any[] = rawData.balances || (Array.isArray(rawData) ? rawData : []) + const failedChunkCount = chunkResults.filter(r => r.error).length + let hadChunkFailures = false + // effectivePubkeys: pubkeys whose chunk succeeded — chains from failed chunks show 0 + let effectivePubkeys = pubkeys + if (failedChunkCount > 0) { + hadChunkFailures = true + const succeeded = pubkeyChunks.length - failedChunkCount + console.warn(`[getBalances] Partial portfolio response: ${succeeded}/${pubkeyChunks.length} chunks succeeded — failed chains will show 0`) + for (let i = 0; i < chunkResults.length; i++) { + if (chunkResults[i].error) { + const chains = pubkeyChunks[i].map((p: any) => p.chainId || String(p.caip).split(':')[0]).join(', ') + console.warn(`[getBalances] Chunk ${i + 1} failed — excluded chains: ${chains}`) + } + } + if (failedChunkCount === pubkeyChunks.length) { + throw new Error(`All ${pubkeyChunks.length} portfolio chunks failed`) + } + // Key by caip:pubkey so a failed Hyperliquid entry (which shares the + // ETH address as pubkey) doesn't exclude all other EVM chains. + const failedPubkeySet = new Set() + for (let i = 0; i < chunkResults.length; i++) { + if (chunkResults[i].error) { + for (const p of pubkeyChunks[i]) failedPubkeySet.add(`${p.caip}:${p.pubkey}`) + } + } + effectivePubkeys = pubkeys.filter(p => !failedPubkeySet.has(`${p.caip}:${p.pubkey}`)) + } + // failedPubkeySet used below to gate DB writes — results always use all pubkeys + // so chains from failed chunks still appear (with 0) rather than vanishing entirely. + const failedPubkeySetForDb = new Set( + effectivePubkeys.length < pubkeys.length + ? pubkeys.filter(p => !effectivePubkeys.includes(p)).map(p => `${p.caip}:${p.pubkey}`) + : [] + ) + console.log(`[getBalances] effectivePubkeys: ${effectivePubkeys.length}/${pubkeys.length} — chains: ${[...new Set(effectivePubkeys.map(p => p.chainId))].join(', ')}`) + const allEntries = chunkResults.flatMap(r => r.entries) console.log(`[getBalances] GetPortfolioBalances response: ${allEntries.length} entries`) // Log TRON-specific entries for debugging @@ -1265,10 +1897,19 @@ const rpc = BrowserView.defineRPC({ console.log(`[getBalances] networkToChain map (${networkToChain.size} entries): ${JSON.stringify(Object.fromEntries(networkToChain))}`) const tokensByChainId = new Map() + const evmTokensByOwner = new Map() let tokensSkippedZero = 0, tokensSkippedNoChain = 0, tokensGrouped = 0 + // Dedup by (caip, ownerAddress) before accumulation so Pioneer returning the same + // token multiple times for the same address doesn't inflate the balance. + const seenByOwnerCaip = new Set() for (const tok of tokenEntries) { const bal = parseFloat(String(tok.balance ?? '0')) if (bal <= 0) { tokensSkippedZero++; continue } + const ownerAddr = String(tok.address || tok.pubkey || '').toLowerCase() + const caipNorm = (tok.caip || '').startsWith('eip155:') ? (tok.caip || '').toLowerCase() : (tok.caip || '') + const ownerCaipKey = `${caipNorm}|${ownerAddr}` + if (seenByOwnerCaip.has(ownerCaipKey)) { tokensSkippedZero++; continue } + seenByOwnerCaip.add(ownerCaipKey) // Determine parent chainId from networkId or CAIP-2 prefix (lowercase — Pioneer may return different casing) const tokNetworkId = (tok.networkId || '').toLowerCase() @@ -1310,26 +1951,62 @@ const rpc = BrowserView.defineRPC({ const existing = tokensByChainId.get(parentChainId) || [] existing.push(token) tokensByChainId.set(parentChainId, existing) + + const ownerAddress = String(tok.address || tok.pubkey || '').toLowerCase() + if (ownerAddress && evmAddressSet.has(ownerAddress)) { + const ownerKey = `${parentChainId}:${ownerAddress}` + const ownerTokens = evmTokensByOwner.get(ownerKey) || [] + ownerTokens.push(token) + evmTokensByOwner.set(ownerKey, ownerTokens) + } tokensGrouped++ } console.debug(`[getBalances] Token grouping: ${tokensGrouped} grouped, ${tokensSkippedZero} skipped (zero bal), ${tokensSkippedNoChain} DROPPED (no parent chain)`) - // Merge user-added custom tokens as placeholders - try { - const customTokens = getCustomTokens() - for (const ct of customTokens) { - const existing = tokensByChainId.get(ct.chainId) || [] - // Skip if Pioneer already returned this token - if (existing.some(t => t.contractAddress?.toLowerCase() === ct.contractAddress.toLowerCase())) continue - existing.push({ - symbol: ct.symbol, name: ct.name, balance: '0', balanceUsd: 0, priceUsd: 0, - caip: `${ct.networkId}/erc20:${ct.contractAddress}`, - contractAddress: ct.contractAddress, networkId: ct.networkId, decimals: ct.decimals, type: 'token', - }) - tokensByChainId.set(ct.chainId, existing) + // Deduplicate tokens within each chain by normalized CAIP. + // EVM addresses are case-insensitive hex — normalize for dedup only (caip on + // the object stays canonical). Non-EVM identifiers (Solana base58 mint, + // Tron base58check) are case-sensitive — keep them exact. + // When Pioneer returns the same ERC-20 for multiple EVM address indices, + // sum their balances so portfolio value is not underreported. + for (const [chainId, chainTokens] of tokensByChainId) { + const seen = new Map() + for (const tok of chainTokens) { + const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip + const existing = seen.get(key) + if (!existing) { + seen.set(key, { ...tok }) + } else { + existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) + existing.balanceUsd += tok.balanceUsd + } + } + if (seen.size < chainTokens.length) { + console.debug(`[getBalances] Deduped ${chainId}: ${chainTokens.length} → ${seen.size} tokens`) + tokensByChainId.set(chainId, [...seen.values()]) } - } catch { /* custom tokens lookup failed, non-fatal */ } + } + + // EVM AssetPage shows per-address tokens from evmTokensByOwner, not tokensByChainId. + // Pioneer returns the same token multiple times per address — dedup that map too. + for (const [ownerKey, ownerTokens] of evmTokensByOwner) { + const seen = new Map() + for (const tok of ownerTokens) { + const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip + const existing = seen.get(key) + if (!existing) { + seen.set(key, { ...tok }) + } else { + existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) + existing.balanceUsd += tok.balanceUsd + } + } + if (seen.size < ownerTokens.length) { + console.debug(`[getBalances] Deduped owner ${ownerKey}: ${ownerTokens.length} → ${seen.size} tokens`) + evmTokensByOwner.set(ownerKey, [...seen.values()]) + } + } // Aggregate BTC entries into one ChainBalance + update per-xpub balances console.debug(`[getBalances] pureNatives count: ${pureNatives.length}`) @@ -1348,6 +2025,7 @@ const rpc = BrowserView.defineRPC({ const selectedXpubStr = btcAccounts.getSelectedXpub()?.xpub for (const entry of pubkeys) { + const isFailedEntry = failedPubkeySetForDb.has(`${entry.caip}:${entry.pubkey}`) if (entry.chainId === 'bitcoin') { // Find the Pioneer response for this xpub const match = pureNatives.find((d: any) => d.pubkey === entry.pubkey) @@ -1364,23 +2042,34 @@ const rpc = BrowserView.defineRPC({ } // Update per-xpub balance in BtcAccountManager + persist to cache. // PRIVACY: Skip DB write for hidden passphrase wallets. + // Skip if this entry came from a failed chunk (don't persist zeros). const xpubBal = String(match?.balance ?? '0') - btcAccounts.updateXpubBalance(entry.pubkey, xpubBal, usd) - try { - const devId = engine.getDeviceState().deviceId - if (devId && !engine.isPassphraseWallet) saveCachedPubkey(devId, 'bitcoin', entry.pubkey, entry.pubkey, match?.address || '', '', xpubBal, usd) - } catch { /* non-fatal */ } + if (!isFailedEntry) { + btcAccounts.updateXpubBalance(entry.pubkey, xpubBal, usd) + try { + const devId = engine.getDeviceState().deviceId + if (devId && !engine.isPassphraseWallet) saveCachedPubkey(devId, 'bitcoin', entry.pubkey, entry.pubkey, match?.address || '', '', xpubBal, usd) + } catch { /* non-fatal */ } + } continue } - // EVM multi-address: aggregate per-chain, update per-address balance + // EVM multi-address: aggregate per-chain and keep per-address chain balances if (evmAddressSet.has(entry.pubkey.toLowerCase())) { const match = pureNatives.find((d: any) => d.caip === entry.caip && d.pubkey === entry.pubkey) || pureNatives.find((d: any) => d.caip === entry.caip && d.address?.toLowerCase() === entry.pubkey.toLowerCase()) const bal = parseFloat(String(match?.balance ?? '0')) const usd = Number(match?.valueUsd ?? 0) - // Accumulate per-address USD for EvmAddressManager - if (usd > 0) evmAddresses.updateAddressBalance(entry.pubkey, usd) + const entryTokens = evmTokensByOwner.get(`${entry.chainId}:${entry.pubkey.toLowerCase()}`) || [] + const entryTokenUsd = entryTokens.reduce((sum, t) => sum + t.balanceUsd, 0) + evmAddresses.setAddressChainBalance(entry.pubkey, entry.chainId, { + chainId: entry.chainId, + symbol: entry.symbol, + balance: bal > 0 ? bal.toFixed(18).replace(/0+$/, '').replace(/\.$/, '') : '0', + balanceUsd: usd + entryTokenUsd, + nativeBalanceUsd: usd, + tokens: entryTokens.length > 0 ? entryTokens : undefined, + }) // Accumulate per-chain totals const existing = evmChainAgg.get(entry.chainId) if (existing) { @@ -1445,9 +2134,65 @@ const rpc = BrowserView.defineRPC({ }) } + // Attach shielded ZEC as a synthetic token under native Zcash so the + // dashboard renders it like an ERC20 sub-row. The Orchard FVK lives + // in the local sidecar; the seed never left the device. + if (zcashPrivacyEnabled && hasFvkLoaded()) { + try { + const shielded = await Promise.race([ + getShieldedBalance(), + new Promise(r => setTimeout(() => r(null), 5000)), + ]) + if (shielded && shielded.confirmed > 0) { + const zcashEntry = results.find(r => r.chainId === 'zcash') + if (zcashEntry) { + const zcashCaip = 'bip122:00040fe8ec8471911baa1db1266ea15d/slip44:133' + const zcashNative = pureNatives.find((d: any) => d.caip === zcashCaip) + const zecPrice = parseFloat(zcashNative?.priceUsd ?? '0') + const zecAmount = shielded.confirmed / 1e8 + const shieldedUsd = zecAmount * zecPrice + zcashEntry.tokens = zcashEntry.tokens || [] + zcashEntry.tokens.push({ + symbol: 'zZEC', + name: 'Shielded ZEC', + balance: zecAmount.toFixed(8), + balanceUsd: shieldedUsd, + priceUsd: zecPrice, + caip: 'bip122:00040fe8ec8471911baa1db1266ea15d/orchard:shielded', + contractAddress: 'orchard', + networkId: 'bip122:00040fe8ec8471911baa1db1266ea15d', + decimals: 8, + type: 'shielded', + }) + zcashEntry.balanceUsd = (zcashEntry.balanceUsd || 0) + shieldedUsd + } + } + } catch (e: any) { + console.warn('[getBalances] Shielded balance fetch failed:', e?.message || e) + } + } - // Push updated BTC accounts to frontend - try { rpc.send['btc-accounts-update'](btcAccounts.toAccountSet()) } catch { /* webview not ready */ } + // Push updated BTC accounts to frontend and sync DB cache with aggregate total. + // The DB entry (used by getCachedBalances → SwapDialog) would otherwise stay + // as a single-xpub value; the in-memory manager always has the correct sum. + // Use the real address from Pioneer (btcSelectedAddress/btcFallbackAddress) when + // available — fall back to xpub only if Pioneer didn't return an address. + { + const btcSet = btcAccounts.toAccountSet() + try { rpc.send['btc-accounts-update'](btcSet) } catch { /* webview not ready */ } + try { + const devId = engine.getDeviceState().deviceId + if (devId && !engine.isPassphraseWallet && parseFloat(btcSet.totalBalance) >= 0) { + updateCachedBalance(devId, { + chainId: 'bitcoin', symbol: 'BTC', + balance: btcSet.totalBalance, + balanceUsd: btcSet.totalBalanceUsd, + nativeBalanceUsd: btcSet.totalBalanceUsd, + address: btcSelectedAddress || btcFallbackAddress || btcAccounts.getSelectedXpub()?.xpub || '', + }) + } + } catch { /* non-fatal */ } + } // Push updated EVM addresses to frontend try { rpc.send['evm-addresses-update'](evmAddresses.toAddressSet()) } catch { /* webview not ready */ } @@ -1461,21 +2206,20 @@ const rpc = BrowserView.defineRPC({ }).catch(() => {}) } - // Cache balances (fire-and-forget) — only on successful Pioneer response. + // Cache balances (fire-and-forget). + // Write partial results even on chunk failures — chains from failed chunks simply + // won't be in results, so the next getCachedBalances staleness check will flag + // them as missing and trigger another refresh. Partial is always better than nothing. // PRIVACY: Skip for passphrase wallets (hidden wallet data must not hit disk). try { const deviceId = engine.getDeviceState().deviceId || 'unknown' if (results.length > 0 && !engine.isPassphraseWallet) setCachedBalances(deviceId, results) } catch { /* never block on cache failure */ } } catch (e: any) { - console.warn('[getBalances] Portfolio API failed:', e.message) - const seen = new Set() - for (const entry of pubkeys) { - // Deduplicate BTC entries in fallback - if (seen.has(entry.chainId)) continue - seen.add(entry.chainId) - results.push({ chainId: entry.chainId, symbol: entry.symbol, balance: '0', balanceUsd: 0, address: entry.pubkey }) - } + const message = getPioneerPortfolioErrorMessage(e) + console.warn('[getBalances] Portfolio API failed:', message) + try { rpc.send['pioneer-error']({ message, url: getPioneerApiBase() }) } catch { /* webview not ready */ } + throw new Error(`Balance server error: ${message}`) } // ── Final audit log ── @@ -1598,18 +2342,41 @@ const rpc = BrowserView.defineRPC({ } } catch { /* cache lookup failed, non-fatal */ } - // Reset per-address balances for this refresh (mirrors getBalances line 991) - if (isEvm) evmAddresses.resetBalances() + // Reset this chain's per-address balances before single-chain refresh. + if (isEvm) evmAddresses.resetBalances(chain.id) try { - const resp = await withTimeout( - pioneer.GetPortfolioBalances( - { pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) }, - { forceRefresh: true } - ), - PIONEER_TIMEOUT_MS, - 'GetPortfolioBalances' - ) + const extraContracts = getCustomTokens() + .filter(ct => ct.chainId === chain.id) + .map(ct => ({ + networkId: ct.networkId, + contractAddress: ct.contractAddress, + decimals: ct.decimals, + symbol: ct.symbol, + name: ct.name, + icon: ct.iconUrl, + })) + const portfolioBody: any = { pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) } + if (extraContracts.length > 0) portfolioBody.extraContracts = extraContracts + let resp: any + try { + resp = await withTimeout( + pioneer.GetPortfolioBalances(portfolioBody, { forceRefresh: true }), + PIONEER_TIMEOUT_MS, + 'GetPortfolioBalances' + ) + } catch (err: any) { + if (!extraContracts.length || !isExtraContractsSchemaError(err)) throw err + console.warn(`[getBalance] ${chain.coin}: Pioneer rejected extraContracts; retrying without custom tokens`) + resp = await withTimeout( + pioneer.GetPortfolioBalances( + { pubkeys: portfolioBody.pubkeys }, + { forceRefresh: true } + ), + PIONEER_TIMEOUT_MS, + 'GetPortfolioBalances' + ) + } const rawData = resp?.data?.data || resp?.data || {} const allEntries: any[] = rawData.balances || (Array.isArray(rawData) ? rawData : []) @@ -1661,6 +2428,7 @@ const rpc = BrowserView.defineRPC({ const selectedXpubStr = isBtc ? btcAccounts.getSelectedXpub()?.xpub : undefined let selectedPkAddress = '' // address from selected xpub (BTC) or sole xpub (other UTXO) let fallbackPkAddress = '' // first Pioneer-returned address from any xpub + const evmNativeByPubkey = new Map() for (const pk of pubkeys) { // Find ONE matching native entry for this requested pubkey (no double-counting) @@ -1671,6 +2439,7 @@ const rpc = BrowserView.defineRPC({ const usd = Number(match?.valueUsd ?? 0) nativeTotalBalance += bal nativeTotalUsd += usd + if (isEvm) evmNativeByPubkey.set(pk.pubkey.toLowerCase(), { balance: bal, usd }) // Capture Pioneer-returned address for this pubkey if (match?.address) { @@ -1688,8 +2457,6 @@ const rpc = BrowserView.defineRPC({ const devId = engine.getDeviceState().deviceId if (devId && !engine.isPassphraseWallet) saveCachedPubkey(devId, 'bitcoin', pk.pubkey, pk.pubkey, match?.address || '', '', xpubBal, usd) } catch { /* non-fatal */ } - } else if (isEvm && usd > 0) { - evmAddresses.updateAddressBalance(pk.pubkey, usd) } } @@ -1704,14 +2471,21 @@ const rpc = BrowserView.defineRPC({ } // Process tokens — already filtered to this chain + our pubkeys + const evmTokensByOwner = new Map() if (tokenEntries.length > 0) { const parsedTokens: TokenBalance[] = [] + // Dedup by (caip, ownerAddress) — same fix as getBalances path + const seenByOwnerCaip = new Set() for (const tok of tokenEntries) { const bal = parseFloat(String(tok.balance ?? '0')) if (bal <= 0) continue + const ownerAddr = String(tok.address || tok.pubkey || '').toLowerCase() + const caipNorm = (tok.caip || '').startsWith('eip155:') ? (tok.caip || '').toLowerCase() : (tok.caip || '') + if (seenByOwnerCaip.has(`${caipNorm}|${ownerAddr}`)) continue + seenByOwnerCaip.add(`${caipNorm}|${ownerAddr}`) const contractMatch = (tok.caip || '').match(/\/(erc20|spl|trc20|token):([^\s]+)/) const contractAddress = contractMatch?.[2] || tok.contract || undefined - parsedTokens.push({ + const token: TokenBalance = { symbol: tok.symbol || '???', name: tok.name || tok.symbol || 'Unknown Token', balance: String(tok.balance ?? '0'), @@ -1724,31 +2498,80 @@ const rpc = BrowserView.defineRPC({ decimals: tok.decimals ?? tok.precision, type: tok.type || 'token', dataSource: tok.dataSource, - }) - } - - // Merge user-added custom tokens as placeholders - try { - const customTokens = getCustomTokens().filter(ct => ct.chainId === chain.id) - for (const ct of customTokens) { - if (parsedTokens.some(t => t.contractAddress?.toLowerCase() === ct.contractAddress.toLowerCase())) continue - parsedTokens.push({ - symbol: ct.symbol, name: ct.name, balance: '0', balanceUsd: 0, priceUsd: 0, - caip: `${ct.networkId}/erc20:${ct.contractAddress}`, - contractAddress: ct.contractAddress, networkId: ct.networkId, decimals: ct.decimals, type: 'token', - }) } - } catch { /* custom tokens lookup failed, non-fatal */ } + parsedTokens.push(token) + if (isEvm) { + const ownerAddress = String(tok.address || tok.pubkey || '').toLowerCase() + if (ownerAddress) { + const ownerTokens = evmTokensByOwner.get(ownerAddress) || [] + ownerTokens.push(token) + evmTokensByOwner.set(ownerAddress, ownerTokens) + } + } + } if (parsedTokens.length > 0) { - tokens = parsedTokens - const tokenUsdTotal = parsedTokens.reduce((sum, t) => sum + t.balanceUsd, 0) + // Deduplicate by normalized CAIP — same EVM-only rule as getBalances. + // EVM: lowercase for dedup; canonical caip on the object stays intact. + // Non-EVM: exact match (Solana/Tron identifiers are case-sensitive). + const seen = new Map() + for (const tok of parsedTokens) { + const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip + const existing = seen.get(key) + if (!existing) { + seen.set(key, { ...tok }) + } else { + existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) + existing.balanceUsd += tok.balanceUsd + } + } + tokens = [...seen.values()] + const tokenUsdTotal = tokens.reduce((sum, t) => sum + t.balanceUsd, 0) balanceUsd += tokenUsdTotal } - console.log(`[getBalance] ${chain.coin}: ${parsedTokens.length} tokens, $${balanceUsd.toFixed(2)} total`) + console.log(`[getBalance] ${chain.coin}: ${tokens?.length ?? 0} tokens, $${balanceUsd.toFixed(2)} total`) + } + + // Dedup per-address EVM token lists before writing to evmAddresses. + // tokensByChainId is already deduped above; evmTokensByOwner feeds AssetPage directly. + if (isEvm) { + for (const [addr, addrTokens] of evmTokensByOwner) { + const seen = new Map() + for (const tok of addrTokens) { + const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip + const existing = seen.get(key) + if (!existing) { + seen.set(key, { ...tok }) + } else { + existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) + existing.balanceUsd += tok.balanceUsd + } + } + if (seen.size < addrTokens.length) evmTokensByOwner.set(addr, [...seen.values()]) + } + } + + if (isEvm) { + for (const pk of pubkeys) { + const ownerAddress = pk.pubkey.toLowerCase() + const native = evmNativeByPubkey.get(ownerAddress) || { balance: 0, usd: 0 } + const ownerTokens = evmTokensByOwner.get(ownerAddress) || [] + const tokenUsdTotal = ownerTokens.reduce((sum, t) => sum + t.balanceUsd, 0) + evmAddresses.setAddressChainBalance(pk.pubkey, chain.id, { + chainId: chain.id, + symbol: chain.symbol, + balance: native.balance > 0 ? native.balance.toFixed(18).replace(/0+$/, '').replace(/\.$/, '') : '0', + balanceUsd: native.usd + tokenUsdTotal, + nativeBalanceUsd: native.usd, + tokens: ownerTokens.length > 0 ? ownerTokens : undefined, + }) + } } } catch (e: any) { - console.warn(`[getBalance] ${chain.coin} portfolio failed:`, e.message) + const message = getPioneerPortfolioErrorMessage(e) + console.warn(`[getBalance] ${chain.coin} portfolio failed:`, message) + try { rpc.send['pioneer-error']({ message, url: getPioneerApiBase() }) } catch { /* webview not ready */ } + throw new Error(`Balance server error: ${message}`) } // If Pioneer failed or returned no address, preserve the cached address // so we don't wipe a previously good address from the shared cache (Finding 3) @@ -1769,7 +2592,21 @@ const rpc = BrowserView.defineRPC({ } // Push updated BTC per-xpub balances — only if manager is hydrated (Finding 2) if (isBtc && btcAccounts.isInitialized && btcAccounts.getAllPubkeyEntries(chain.caip).length > 0) { - try { rpc.send['btc-accounts-update'](btcAccounts.toAccountSet()) } catch { /* webview not ready */ } + const btcSet = btcAccounts.toAccountSet() + try { rpc.send['btc-accounts-update'](btcSet) } catch { /* webview not ready */ } + // Sync DB cache so getCachedBalances returns aggregate (not stale single-xpub) + try { + const devId = engine.getDeviceState().deviceId + if (devId && !engine.isPassphraseWallet) { + updateCachedBalance(devId, { + chainId: 'bitcoin', symbol: 'BTC', + balance: btcSet.totalBalance, + balanceUsd: btcSet.totalBalanceUsd, + nativeBalanceUsd: btcSet.totalBalanceUsd, + address: result.address || btcAccounts.getSelectedXpub()?.xpub || '', + }) + } + } catch { /* non-fatal */ } } return result @@ -1968,8 +2805,9 @@ const rpc = BrowserView.defineRPC({ // Track broadcast in api_log + notify frontend. // PRIVACY: Skip DB write for passphrase wallets (still push to UI). - const logEntry: ApiLogEntry = { method: 'RPC', route: 'broadcastTx', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: chain.symbol, activityType: 'broadcast' } - if (!engine.isPassphraseWallet) insertApiLog(logEntry) + const scope = getWalletDbScope() + const logEntry: ApiLogEntry = { ...(scope || {}), method: 'RPC', route: 'broadcastTx', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: chain.symbol, activityType: 'broadcast' } + if (!engine.isPassphraseWallet && scope) insertApiLog(logEntry) try { rpc.send['api-log'](logEntry) } catch { /* webview not ready */ } return result @@ -2173,6 +3011,15 @@ const rpc = BrowserView.defineRPC({ const addr = params.contractAddress.trim() if (!/^0x[a-fA-F0-9]{40}$/.test(addr)) throw new Error('Invalid contract address') const meta = await getTokenMetadata(rpcUrl, addr) + // Best-effort logo resolve. Don't block persistence on a slow CDN + // or rate-limited CoinGecko response — fail open to no icon. + const { resolveTokenIcon } = await import('./evm-token-icons') + let iconUrl: string | undefined + try { + iconUrl = (await resolveTokenIcon(params.chainId, addr)) || undefined + } catch (e: any) { + console.warn(`[addCustomToken] icon resolve threw, persisting without:`, e?.message || e) + } const token: CustomToken = { chainId: params.chainId, contractAddress: addr, @@ -2180,6 +3027,7 @@ const rpc = BrowserView.defineRPC({ name: meta.name, decimals: meta.decimals, networkId: chain.networkId, + iconUrl, } dbAddCustomToken(token) return token @@ -2190,6 +3038,24 @@ const rpc = BrowserView.defineRPC({ getCustomTokens: async () => { return getCustomTokens() }, + setCustomTokenIcon: async (params) => { + // User-supplied icon (data URL or http(s) URL). Cap at ~256KB raw bytes + // so a long-tail upload doesn't bloat sqlite or the in-memory token list. + // 256KB ≈ 350K base64 chars. Reject schemes other than data: / http(s): + // to keep the value renderable from the WebView and to avoid storing + // random arbitrary protocols. + const u = (params.iconUrl || '').trim() + if (!u) throw new Error('iconUrl required') + if (!/^(data:image\/(png|jpe?g|webp|svg\+xml|gif);base64,|https?:\/\/)/i.test(u)) { + throw new Error('iconUrl must be a data:image/* (base64) or http(s) URL') + } + if (u.length > 350_000) throw new Error('Icon too large (max ~256KB)') + const ok = dbSetCustomTokenIcon(params.chainId, params.contractAddress, u) + if (!ok) throw new Error('Token row not found — Add it first') + const found = getCustomTokens().find(t => t.chainId === params.chainId && t.contractAddress.toLowerCase() === params.contractAddress.toLowerCase()) + if (!found) throw new Error('Token row not found after update') + return found + }, // ── Chain discovery (Pioneer catalog) ──────────────────── browseChains: async (params) => { @@ -2265,11 +3131,15 @@ const rpc = BrowserView.defineRPC({ if (!caip) throw new Error('caip required') if (params.status !== 'visible' && params.status !== 'hidden') throw new Error('status must be visible or hidden') dbSetTokenVisibility(caip, params.status) + // Notify any other view (Dashboard, etc.) that visibility changed + // so it can refetch instead of holding the stale on-mount snapshot. + try { rpc.send['token-visibility-changed']({ caip, status: params.status }) } catch { /* webview not ready */ } }, removeTokenVisibility: async (params) => { const caip = params.caip?.trim() if (!caip) throw new Error('caip required') dbRemoveTokenVisibility(caip) + try { rpc.send['token-visibility-changed']({ caip, status: null }) } catch { /* webview not ready */ } }, getTokenVisibilityMap: async () => { const map = getAllTokenVisibility() @@ -2285,6 +3155,10 @@ const rpc = BrowserView.defineRPC({ const fvkLoaded = hasFvkLoaded() const cached = getCachedFvk() const scanState = getScanState() + // Privacy tab opening is the natural trigger for "validate the local + // wallet against the chain". Fires once per session, in the background; + // status response stays fast, scan-progress events drive any UI. + maybeStartBackgroundWalletVerification() const result = { ready: sidecarReady, fvk_loaded: fvkLoaded, @@ -2292,8 +3166,10 @@ const rpc = BrowserView.defineRPC({ fvk: cached?.fvk ?? null, synced_to: scanState.syncedTo, keepkey_release_block: scanState.releaseBlock, + verified: zcashVerifiedThisSession, + verifying: zcashBackgroundVerifyInFlight, } - console.log(`[zcash] zcashShieldedStatus → ready=${result.ready} fvk=${fvkLoaded} synced_to=${scanState.syncedTo} addr=${cached?.address?.slice(0, 20) ?? 'none'}`) + console.log(`[zcash] zcashShieldedStatus → ready=${result.ready} fvk=${fvkLoaded} verified=${result.verified} verifying=${result.verifying} synced_to=${scanState.syncedTo} addr=${cached?.address?.slice(0, 20) ?? 'none'}`) return result }, zcashShieldedInit: async (params) => { @@ -2301,16 +3177,27 @@ const rpc = BrowserView.defineRPC({ // If FVK is already loaded from DB, return it immediately const cached = getCachedFvk() if (cached) return cached - // Otherwise get from device + // Otherwise get from device — newly-loaded FVK invalidates any prior + // wallet validation, since notes for a different ak shouldn't carry over. if (!engine.wallet) throw new Error('No device connected') + // initializeOrchardFromDevice updates the in-process FVK cache itself. const result = await initializeOrchardFromDevice(engine.wallet as any, params?.account ?? 0) - setCachedFvk(result.address, result.fvk) + zcashVerifiedThisSession = false return result }, zcashShieldedScan: async (params) => { if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') + if (!engine.wallet) throw new Error('No device connected') + // Direct RPC callers (and the REST handler that mirrors this) may not + // have gone through the Privacy tab's auto-init path, so the sidecar + // could have no FVK loaded — `scanOrchardNotes` would then fail with + // "No FVK set". Refresh from device first if needed. + await ensureFvkLoaded(engine.wallet, 0) const result = await scanOrchardNotes(params?.startHeight, params?.fullRescan) if (result?.synced_to != null) updateSyncedTo(result.synced_to) + // A successful scan validates the wallet against the chain — even an + // incremental one from synced_to brings the unspent set up to truth. + zcashVerifiedThisSession = true return result }, zcashShieldedBalance: async () => { @@ -2320,20 +3207,62 @@ const rpc = BrowserView.defineRPC({ zcashShieldedSend: async (params) => { if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') if (!engine.wallet) throw new Error('No device connected') + await ensureFvkLoaded(engine.wallet, 0) + await ensureZcashScanFresh() // FVK already loaded means device supports Orchard — skip version check // (version string may not be populated yet at call time) - return await sendShielded(engine.wallet as any, { + // On the emulator, route the device-signing call through emuSigningOp so the + // confirm UI pops + ButtonAck/DLD get pre-written. Without this the firmware + // busy-loops in confirm_helper() and the watchdog SIGKILLs the bun process. + const signWrap = engine.isEmulator + ? (fn: () => Promise) => emuSigningOp(fn, { + operation: 'zcashShieldedSend', chain: 'Zcash', + to: params.recipient, value: String(params.amount), memo: params.memo, + }) as Promise + : undefined + try { rpc.send['send-progress']({ step: 'building' }) } catch { /* webview not ready */ } + const onProgress = (step: string) => { + try { rpc.send['send-progress']({ step }) } catch { /* webview not ready */ } + } + const result = await sendShielded(engine.wallet as any, { recipient: params.recipient, amount: params.amount, memo: params.memo, + }, { signWrap, onProgress }) + try { rpc.send['send-progress']({ step: 'complete', detail: result.txid }) } catch { /* webview not ready */ } + return result + }, + zcashTransparentBalance: async (params) => { + if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') + if (!engine.wallet) throw new Error('No device connected') + const account = params?.account ?? 0 + const wallet = engine.wallet + const path = [0x80000000 + 44, 0x80000000 + 133, 0x80000000 + account, 0, 0] + const addressResult = await wallet.btcGetAddress({ + addressNList: path, coin: "Zcash", scriptType: "p2pkh", showDisplay: false, }) + const address = typeof addressResult === 'string' ? addressResult : addressResult?.address + if (!address) throw new Error("Failed to derive transparent ZEC address from device") + const { getShieldableTransparentBalance } = await import("./txbuilder/zcash-shield") + const pioneer = await getPioneer() + const tipHeight = getScanState().syncedTo + const totals = await getShieldableTransparentBalance(pioneer, address, tipHeight) + return { + address, + balanceZat: totals.matureZat, + pendingZat: totals.pendingZat, + matureCount: totals.matureCount, + pendingCount: totals.pendingCount, + } }, zcashShieldZec: async (params) => { if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') if (!engine.wallet) throw new Error('No device connected') + await ensureFvkLoaded(engine.wallet, params.account ?? 0) + await ensureZcashScanFresh() // Transparent shielding uses standard ECDSA (secp256k1) for transparent inputs // + Orchard RedPallas for the shielded output. The ECDSA part works on any - // firmware; the Orchard part needs >= 7.14.0 (checked by zcashShieldedInit). + // firmware; the Orchard part needs >= 7.15.0 (checked by zcashShieldedInit). const zcashDef = CHAINS.find(c => c.id === 'zcash-shielded') if (!zcashDef) { throw new Error('Zcash shielded chain definition not found') @@ -2341,10 +3270,18 @@ const rpc = BrowserView.defineRPC({ const { shieldZec } = await import("./txbuilder/zcash-shield") const pioneer = await getPioneer() try { rpc.send['shield-progress']({ step: 'building' }) } catch { /* webview not ready */ } + const signWrap = engine.isEmulator + ? (fn: () => Promise) => emuSigningOp(fn, { + operation: 'zcashShieldZec', chain: 'Zcash', value: String(params.amount), + }) as Promise + : undefined + const onProgress = (step: string) => { + try { rpc.send['shield-progress']({ step }) } catch { /* webview not ready */ } + } const result = await shieldZec(engine.wallet as any, pioneer, { amount: params.amount, account: params.account, - }) + }, { signWrap, onProgress }) try { rpc.send['shield-progress']({ step: 'complete', detail: result.txid }) } catch { /* webview not ready */ } return result }, @@ -2352,13 +3289,24 @@ const rpc = BrowserView.defineRPC({ zcashDeshieldZec: async (params) => { if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') if (!engine.wallet) throw new Error('No device connected') + await ensureFvkLoaded(engine.wallet, 0) + await ensureZcashScanFresh() const { deshieldZec } = await import("./txbuilder/zcash-deshield") try { rpc.send['deshield-progress']({ step: 'building' }) } catch { /* webview not ready */ } + const signWrap = engine.isEmulator + ? (fn: () => Promise) => emuSigningOp(fn, { + operation: 'zcashDeshieldZec', chain: 'Zcash', + to: params.recipient, value: String(params.amount), + }) as Promise + : undefined + const onProgress = (step: string) => { + try { rpc.send['deshield-progress']({ step }) } catch { /* webview not ready */ } + } const result = await deshieldZec(engine.wallet as any, { recipient: params.recipient, amount: params.amount, account: params.account, - }) + }, { signWrap, onProgress }) try { rpc.send['deshield-progress']({ step: 'complete', detail: result.txid }) } catch { /* webview not ready */ } return result }, @@ -2374,6 +3322,12 @@ const rpc = BrowserView.defineRPC({ return await backfillMemos() }, + zcashDisplayAddress: async (params) => { + if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') + if (!engine.wallet) throw new Error('No device connected') + return await displayOrchardAddressOnDevice(engine.wallet as any, params?.account ?? 0) + }, + zcashDiagnoseAnchor: async (params: any) => { if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') const { diagnoseAnchor } = await import("./zcash-sidecar") @@ -2381,15 +3335,15 @@ const rpc = BrowserView.defineRPC({ }, // ── Pairing & Signing approval ─────────────────────────── + // Window-level release is handled by onPairDismissed (fires from the + // try/finally on auth.requestPair) — don't double-release here. approvePairing: async () => { const apiKey = auth.approvePairing() if (!apiKey) throw new Error('No pending pairing request') - try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } return { apiKey } }, rejectPairing: async () => { auth.rejectPairing() - try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } }, approveSigningRequest: async (params) => { if (!auth.approveSigningRequest(params.id)) throw new Error('No pending signing request with that id') @@ -2578,6 +3532,18 @@ const rpc = BrowserView.defineRPC({ return { code: data.code, expiresAt: data.expiresAt, expiresIn: data.expiresIn, qrPayload } }, + // ── Window Focus ───────────────────────────────────────── + getWindowFocusState: async () => { + return { refs: _alwaysOnTopRefs, alwaysOnTop: _alwaysOnTopRefs > 0 } + }, + forceReleaseWindowFocus: async () => { + if (_alwaysOnTopRefs > 0) { + console.warn(`[window-focus] Force-releasing stuck always-on-top (refs was ${_alwaysOnTopRefs})`) + _alwaysOnTopRefs = 0 + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + } + }, + // ── App Settings ───────────────────────────────────────── getAppSettings: async () => { return getAppSettings() @@ -2597,6 +3563,11 @@ const rpc = BrowserView.defineRPC({ resetPioneer() chainCatalog = [] catalogLoadedAt = 0 + // Flush swap asset cache too — without this, the 5-minute TTL + // keeps the previous server's asset list (e.g. missing TRON.USDT) + // sticky after the user repoints to a server that lists more. + const { clearSwapCache } = await import('./swap') + clearSwapCache() console.log('[settings] Pioneer API base set to:', url || '(default)') return getAppSettings() }, @@ -2631,7 +3602,7 @@ const rpc = BrowserView.defineRPC({ } catch (e: any) { console.warn(`[swap-tracker] Failed to send '${msg}':`, e.message) } - }) + }, { getDeviceId: () => getWalletDbScope()?.deviceId, getWalletId: () => getWalletDbScope()?.walletId }) }).catch((e) => { console.error('[swap-tracker] Failed to initialize swap tracker:', e.message || e) }) @@ -2639,10 +3610,10 @@ const rpc = BrowserView.defineRPC({ return getAppSettings() }, setBip85Enabled: async (params) => { - // BIP-85 requires firmware >= 7.15.0 + // BIP-85 requires firmware >= 7.16.0 const fwVer = engine.getDeviceState().firmwareVersion - if (params.enabled && (!fwVer || versionCompare(fwVer, '7.15.0') < 0)) { - console.warn(`[settings] BIP-85 blocked — firmware ${fwVer || 'unknown'} < 7.15.0`) + if (params.enabled && (!fwVer || versionCompare(fwVer, '7.16.0') < 0)) { + console.warn(`[settings] BIP-85 blocked — firmware ${fwVer || 'unknown'} < 7.16.0`) return getAppSettings() } bip85Enabled = params.enabled @@ -2680,10 +3651,14 @@ const rpc = BrowserView.defineRPC({ return getAppSettings() }, setZcashPrivacyEnabled: async (params) => { - // Zcash shielded requires firmware >= 7.14.0 + // Must match shared/chains.ts (zcash + zcash-shielded both at 7.15.0) + // and the helpers in txbuilder/zcash-shield.ts that require + // ZcashTransparentInput support (also 7.15.0). Letting users enable + // the feature on 7.14.0 only to have every action fail downstream is + // worse than blocking it here. const fwVer = engine.getDeviceState().firmwareVersion - if (params.enabled && (!fwVer || versionCompare(fwVer, '7.14.0') < 0)) { - console.warn(`[settings] Zcash privacy blocked — firmware ${fwVer || 'unknown'} < 7.14.0`) + if (params.enabled && (!fwVer || versionCompare(fwVer, '7.15.0') < 0)) { + console.warn(`[settings] Zcash privacy blocked — firmware ${fwVer || 'unknown'} < 7.15.0`) return getAppSettings() } zcashPrivacyEnabled = params.enabled @@ -2759,6 +3734,8 @@ const rpc = BrowserView.defineRPC({ resetPioneer() chainCatalog = [] catalogLoadedAt = 0 + const { clearSwapCache } = await import('./swap') + clearSwapCache() console.log('[settings] Active server removed, reset to default') } console.log('[settings] Pioneer server removed:', url) @@ -2778,9 +3755,12 @@ const rpc = BrowserView.defineRPC({ } catch (e: any) { throw new Error(`Health check failed for ${healthUrl}: ${e.message}`) } - // Find the default server — if switching to default, clear the override - const defaultServer = servers.find(s => s.isDefault) - if (defaultServer && defaultServer.url === url) { + // If switching to the built-in hardcoded default, clear the override so + // getPioneerApiBase() falls back naturally. Otherwise store the URL. + // NOTE: do NOT use the DB isDefault flag here — that flag is user-managed + // (e.g. the user may have marked a custom server as "default") and would + // cause the wrong URL to be silently cleared. + if (url === DEFAULT_API_BASE) { setSetting('pioneer_api_base', '') } else { setSetting('pioneer_api_base', url) @@ -2788,6 +3768,8 @@ const rpc = BrowserView.defineRPC({ resetPioneer() chainCatalog = [] catalogLoadedAt = 0 + const { clearSwapCache } = await import('./swap') + clearSwapCache() console.log('[settings] Active Pioneer server set to:', url) return getAppSettings() }, @@ -2796,10 +3778,13 @@ const rpc = BrowserView.defineRPC({ getApiLogs: async (params) => { // PRIVACY: Don't expose standard-wallet activity logs during hidden sessions. if (engine.isPassphraseWallet) return [] - return getApiLogs(params?.limit ?? 200, params?.offset ?? 0) + const scope = getWalletDbScope() + if (!scope) return [] + return getApiLogs(params?.limit ?? 200, params?.offset ?? 0, scope.deviceId, scope.walletId) }, clearApiLogs: async () => { - clearApiLogs() + const scope = getWalletDbScope() + if (scope) clearApiLogs(scope.deviceId, scope.walletId) }, // ── Reports ───────────────────────────────────────────── @@ -2995,9 +3980,97 @@ const rpc = BrowserView.defineRPC({ const { getSwapAssets } = await import('./swap') return await getSwapAssets() }, + + getSwapHealth: async () => { + const base = await (await import('./pioneer')).getPioneerApiBase() + try { + const resp = await fetch(`${base}/api/v1/swap/health`, { signal: AbortSignal.timeout(8000) }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() as any + return data as import('../shared/types').SwapHealth + } catch (e: any) { + // Pioneer unreachable — return offline for all known integrations + console.warn('[swap] getSwapHealth failed:', e?.message) + return { + fetchedAt: Date.now(), + integrations: [ + { key: 'thorchain', label: 'THORChain', status: 'offline' as const }, + { key: 'mayachain', label: 'Mayachain', status: 'offline' as const }, + { key: 'shapeshift', label: 'ShapeShift', status: 'offline' as const }, + { key: 'relay', label: 'Relay', status: 'offline' as const }, + ], + } + } + }, + + /** Look up an unknown EVM token by contract address. + * Tries Ethereum + common L2s/sidechains in parallel via Pioneer's + * GetMarketInfo + GetTokenDecimals. Frontend uses this to "add custom + * token" when the user pastes a contract into the picker search and + * nothing matches. */ + lookupTokenContract: async (params) => { + if (!swapsEnabled) return { hits: [], reason: 'swaps-disabled' as string | undefined } + const raw = (params.contractAddress || '').trim() + if (!/^0x[a-fA-F0-9]{40}$/.test(raw)) { + return { hits: [] as SwapAsset[], reason: 'invalid-evm-contract' } + } + const lower = raw.toLowerCase() + + /* When the user specifies a chainId, only probe that one. Otherwise + * try every EVM RPC we have configured in parallel — direct + * on-chain ERC20 reads (name/symbol/decimals) work even for + * tokens Pioneer hasn't indexed yet. */ + const chainsToProbe = params.chainId + ? [params.chainId.replace(/^eip155:/, '')] + : Object.keys(EVM_RPC_URLS) + + const allChains = getAllChains() + const hits = (await Promise.all(chainsToProbe.map(async (numericId) => { + const rpcUrl = EVM_RPC_URLS[numericId] + if (!rpcUrl) return null + // Resolve vault's internal chain id (e.g. 'base') from the EIP-155 + // network id. SwapAsset.chainId per types.ts is the vault id, NOT + // CAIP-2 — every downstream consumer (balance lookup, addCustomToken + // handler, swap-discovery merge) keys on it. Returning the CAIP-2 + // form here previously silently broke the picker's add flow: + // keepKeyToAddress/balances find returned undefined, canQuote was + // false, and no quote ever fired. + const networkId = `eip155:${numericId}` + const vaultChain = allChains.find(c => c.networkId === networkId) + if (!vaultChain) return null + try { + const meta = await withTimeout( + getTokenMetadata(rpcUrl, lower), + 8000, + `getTokenMetadata(${numericId})`, + ) + /* Reject empty responses — RPCs sometimes return zero-length + * strings for EOA addresses or bogus contracts. We need a real + * symbol + decimals to safely build a swap. */ + if (!meta.symbol || typeof meta.decimals !== 'number') return null + const caip = `${networkId}/erc20:${lower}` + return { + asset: meta.symbol, + caip, + chainId: vaultChain.id, + chainFamily: 'evm', + contractAddress: lower, + decimals: meta.decimals, + symbol: meta.symbol, + name: meta.name || meta.symbol, + } as SwapAsset + } catch (e: any) { + /* Per-chain failure is fine — most RPCs will return "execution + * reverted" because the contract doesn't exist on that chain. */ + return null + } + }))).filter((h): h is SwapAsset => h !== null) + + return { hits } + }, getSwapQuote: async (params) => { if (!swapsEnabled) throw new Error('Swaps feature is disabled') - const { getSwapQuote, THOR_TO_CHAIN, parseThorAsset } = await import('./swap') + const { getSwapQuote } = await import('./swap') // Resolve xpub addresses to real receive addresses for UTXO chains. // ChainBalance.address can be an xpub when Pioneer doesn't return @@ -3006,15 +4079,12 @@ const rpc = BrowserView.defineRPC({ 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 => { + // CAIP-driven: find vault chain by matching CAIP-19 directly. + const resolveAddr = async (caip: 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) + const chainDef = getAllChains().find(c => c.caip === caip) if (!chainDef || chainDef.chainFamily !== 'utxo') return addr try { - // Use selected BTC account path/scriptType when available const selected = chainDef.id === 'bitcoin' && btcAccounts.isInitialized ? btcAccounts.getSelectedXpub() : undefined // selected.path is account-level (3 elements: m/purpose'/0'/account') @@ -3030,32 +4100,32 @@ const rpc = BrowserView.defineRPC({ }) const resolved = typeof result === 'string' ? result : result?.address if (resolved) { - console.log(`[swap] Resolved xpub → ${resolved} for ${thorAsset}`) + console.log(`[swap] Resolved xpub → ${resolved} for ${caip}`) return resolved } } catch (e: any) { - console.warn(`[swap] Failed to resolve xpub for ${thorAsset}: ${e.message}`) + console.warn(`[swap] Failed to resolve xpub for ${caip}: ${e.message}`) } return addr } params = { ...params, - fromAddress: await resolveAddr(params.fromAsset, params.fromAddress), - toAddress: await resolveAddr(params.toAsset, params.toAddress), + fromAddress: await resolveAddr(params.fromCaip, params.fromAddress), + toAddress: await resolveAddr(params.toCaip, 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`) + throw new Error(`Could not resolve source address for ${params.fromCaip} — 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`) + throw new Error(`Could not resolve destination address for ${params.toCaip} — device may be locked or disconnected`) } const quote = await getSwapQuote(params) // Cache quote so executeSwap can pass real data to the tracker - const cacheKey = `${params.fromAsset}-${params.toAsset}-${params.amount}-${params.slippageBps || 300}-${params.fromAddress}-${params.toAddress}` + const cacheKey = `${params.fromCaip}-${params.toCaip}-${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) @@ -3076,10 +4146,11 @@ const rpc = BrowserView.defineRPC({ 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) } - }) + }, { getDeviceId: () => getWalletDbScope()?.deviceId, getWalletId: () => getWalletDbScope()?.walletId }) } const result = await executeSwap(params, { wallet: engine.wallet, @@ -3099,21 +4170,24 @@ const rpc = BrowserView.defineRPC({ wrapSign: engine.isEmulator ? (fn, details) => emuSigningOp(fn, details) : (fn) => fn(), + pushSubStage: (stage) => { + try { rpc.send["swap-substage"]({ stage }) } catch { /* webview not ready */ } + }, }) // Look up cached quote for real tracker data - // Match by asset pair + amount + inboundAddress to avoid collisions between + // Match by CAIP pair + amount + inboundAddress to avoid collisions between // quotes that share the same pair/amount but differ in slippage/addresses let cachedQuote: Awaited> | undefined for (const [key, val] of swapQuoteCache) { - // Key format: fromAsset-toAsset-amount-slippageBps-fromAddress-toAddress - // Match on the asset-pair+amount prefix AND inboundAddress from the quote - const keyPrefix = `${params.fromAsset}-${params.toAsset}-${params.amount}-` + // Key format: fromCaip-toCaip-amount-slippageBps-fromAddress-toAddress + const keyPrefix = `${params.fromCaip}-${params.toCaip}-${params.amount}-` if (key.startsWith(keyPrefix) && val.inboundAddress === params.inboundAddress) { cachedQuote = val break } } if (!cachedQuote) console.warn('[index] No cached quote for swap tracker — using fallback data') + const scope = getWalletDbScope() // Register swap for tracking (non-blocking) try { trackSwap(result, params, { @@ -3126,17 +4200,16 @@ const rpc = BrowserView.defineRPC({ 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', - }, { skipPersist: engine.isPassphraseWallet }) + swapper: cachedQuote?.swapper, + }, { skipPersist: engine.isPassphraseWallet || !scope, deviceId: scope?.deviceId, walletId: scope?.walletId }) } catch (e: any) { console.warn('[index] Failed to register swap for tracking:', e.message) } // Track swap in api_log. PRIVACY: Skip DB write for passphrase wallets. - if (!engine.isPassphraseWallet) { + if (!engine.isPassphraseWallet && scope) { const fromChain = getAllChains().find(c => c.id === params.fromChainId) - insertApiLog({ method: 'RPC', route: 'executeSwap', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: fromChain?.symbol || params.fromChainId, activityType: 'swap' }) + insertApiLog({ ...scope, method: 'RPC', route: 'executeSwap', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: fromChain?.symbol || params.fromChainId, activityType: 'swap' }) } return result }, @@ -3144,21 +4217,63 @@ const rpc = BrowserView.defineRPC({ if (!swapsEnabled) return [] if (engine.isPassphraseWallet) return [] const { getPendingSwaps } = await import('./swap-tracker') - return getPendingSwaps() + const scope = getWalletDbScope() + if (!scope) return [] + return getPendingSwaps(scope.deviceId, scope.walletId) }, dismissSwap: async (params) => { const { dismissSwap } = await import('./swap-tracker') dismissSwap(params.txid) }, + previewSwapBuild: async (params) => { + if (!swapsEnabled) throw new Error('Swaps feature is disabled') + if (!engine.wallet) throw new Error('No device connected') + const { previewSwapBuild, NOOP_PUSH_SUBSTAGE } = await import('./swap') + return previewSwapBuild(params, { + wallet: engine.wallet, + getAllChains, + getRpcUrl, + getBtcXpub: () => { + if (btcAccounts.isInitialized) { + const selected = btcAccounts.getSelectedXpub() + if (selected) return { xpub: selected.xpub, accountPath: selected.path } + } + return undefined + }, + getAllBtcXpubs: () => { + if (btcAccounts.isInitialized) return btcAccounts.getFundedXpubs() + return [] + }, + wrapSign: (fn) => fn(), // unused in preview + pushSubStage: NOOP_PUSH_SUBSTAGE, + }) + }, // ── Swap History (SQLite-persisted) ───────────────────── getSwapByTxid: async (params) => { // PRIVACY: Don't expose standard-wallet swap records during hidden sessions. if (engine.isPassphraseWallet) return null - const record = getSwapHistoryByTxid(params.txid) + const scope = getWalletDbScope() + if (!scope) return null + const record = getSwapHistoryByTxid(params.txid, scope.deviceId, scope.walletId) if (!record) return null - const { inferConfirmationsFromStatus } = await import('./swap-tracker') + const { inferConfirmationsFromStatus, getPendingSwaps } = await import('./swap-tracker') + // Prefer the in-memory tracker copy when present — it has live + // outboundConfirmations / required / swapper that the DB row doesn't + // store. Falls back to the persisted record otherwise. + const live = getPendingSwaps(scope.deviceId, scope.walletId).find(s => s.txid === record.txid) + // Lazy CAIP backfill for swaps inserted before the from_caip/to_caip + // columns existed — derive on the fly from the THORChain asset id. + let fromCaip = record.fromCaip + let toCaip = record.toCaip + if (!fromCaip || !toCaip) { + const { assetToCaip } = await import('./swap-parsing') + if (!fromCaip) try { fromCaip = assetToCaip(record.fromAsset) } catch { /* unknown chain */ } + if (!toCaip) try { toCaip = assetToCaip(record.toAsset) } catch { /* unknown chain */ } + } return { + deviceId: record.deviceId, + walletId: record.walletId, txid: record.txid, fromAsset: record.fromAsset, toAsset: record.toAsset, @@ -3166,31 +4281,76 @@ const rpc = BrowserView.defineRPC({ toSymbol: record.toSymbol, fromChainId: record.fromChainId, toChainId: record.toChainId, + fromCaip, + toCaip, fromAmount: record.fromAmount, - expectedOutput: record.quotedOutput, + expectedOutput: record.receivedOutput || record.quotedOutput, + receivedOutput: record.receivedOutput, memo: record.memo, inboundAddress: record.inboundAddress, router: record.router, integration: record.integration, + swapper: record.swapper || live?.swapper, status: record.status, - confirmations: inferConfirmationsFromStatus(record.status), + confirmations: live?.confirmations ?? inferConfirmationsFromStatus(record.status), + outboundConfirmations: live?.outboundConfirmations, + outboundRequiredConfirmations: live?.outboundRequiredConfirmations, outboundTxid: record.outboundTxid, + error: record.error, createdAt: record.createdAt, updatedAt: record.updatedAt, + completedAt: record.completedAt, estimatedTime: record.estimatedTimeSeconds, + slippageBps: record.slippageBps, + relayRequestId: live?.relayRequestId ?? record.relayRequestId, } }, + refreshSwap: async (params) => { + // PRIVACY: Standard-wallet swaps are not refreshable from a hidden session. + if (engine.isPassphraseWallet) return null + const { refreshSwap } = await import('./swap-tracker') + const scope = getWalletDbScope() + if (!scope) return null + return await refreshSwap(params.txid, scope.deviceId, scope.walletId) + }, + debugSwapLookup: async (params) => { + // PRIVACY: Mirror getSwapByTxid / refreshSwap — passphrase sessions + // must not see standard-wallet diagnostic data. The function-level + // noPersistSwaps gate inside debugSwapLookup catches passphrase- + // tagged txids regardless of caller; this is the session-level + // gate that refuses the call entirely from a hidden session. + if (engine.isPassphraseWallet) return null + const { debugSwapLookup } = await import('./swap-tracker') + const scope = getWalletDbScope() + if (!scope) return null + return await debugSwapLookup(params.txid, scope.deviceId, scope.walletId) + }, getSwapHistory: async (params) => { if (engine.isPassphraseWallet) return [] - return getSwapHistory(params || undefined) + const scope = getWalletDbScope() + if (!scope) return [] + return getSwapHistory({ ...(params || {}), ...scope }) }, getSwapHistoryStats: async () => { - if (engine.isPassphraseWallet) return { total: 0, completed: 0, failed: 0, pending: 0, totalVolumeUsd: 0 } - return getSwapHistoryStats() + if (engine.isPassphraseWallet) return { totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 } + const scope = getWalletDbScope() + if (!scope) return { totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 } + return getSwapHistoryStats(scope.deviceId, scope.walletId) }, + // Fire-and-forget mirror — SwapDialog calls this on every state change + // so Bun (and REST) can observe what the user sees. Phase 'closed' on + // dialog dismount resets the snapshot. + publishSwapUiState: async (params) => { + swapUiState = params + swapUiUpdatedAt = Date.now() + }, + exportSwapReport: async (params) => { if (engine.isPassphraseWallet) throw new Error('Swap reports are not available for passphrase-protected wallets (privacy).') + const scope = getWalletDbScope() + if (!scope) throw new Error('No device connected') const records = getSwapHistory({ + ...scope, fromDate: params.fromDate, toDate: params.toDate, limit: 10000, @@ -3220,7 +4380,9 @@ const rpc = BrowserView.defineRPC({ getRecentActivity: async (params) => { // PRIVACY: Don't expose standard-wallet activity during hidden sessions. if (engine.isPassphraseWallet) return [] - return getRecentActivityFromLog(params?.limit || 50, params?.chainId) + const scope = getWalletDbScope() + if (!scope) return [] + return getRecentActivityFromLog(params?.limit || 50, params?.chainId, scope.deviceId, scope.walletId) }, scanChainHistory: async (params) => { const chain = getAllChains().find(c => c.id === params.chainId) @@ -3231,79 +4393,22 @@ const rpc = BrowserView.defineRPC({ if (engine.isPassphraseWallet) { throw new Error('Chain history scanning is not available for passphrase-protected wallets (privacy).') } + if (!engine.wallet) throw new Error('No device connected') + const scope = getWalletDbScope() + if (!scope) throw new Error('Wallet scope is not ready. Unlock the device and wait for seed identity.') - // Get the address/xpub for this chain from cached balances - // UTXO chains store xpub, account-based chains store address - const deviceId = engine.getDeviceState().deviceId - if (!deviceId) throw new Error('No device connected') - const cachedBalances = getCachedBalances(deviceId) - const chainBalance = cachedBalances?.balances?.find(b => b.chainId === params.chainId) - const pubkey = chainBalance?.address - if (!pubkey) throw new Error(`No cached address for ${chain.symbol} — load balances first`) - - const pioneer = await getPioneer() - console.log(`[activity] Scanning ${chain.symbol} history for ${chain.chainFamily === 'utxo' ? 'xpub' : 'address'}: ${pubkey.slice(0, 16)}...`) - - const resp = await withTimeout( - pioneer.GetTransactionHistory({ queries: [{ pubkey, caip: chain.caip }] }), - PIONEER_TIMEOUT_MS, - `GetTransactionHistory(${chain.symbol})` - ) - const data = resp?.data || resp - const histories = data?.histories || data?.data?.histories || [] - const txs: any[] = histories[0]?.transactions || [] - - if (txs.length === 0) { - console.log(`[activity] No transactions found for ${chain.symbol}`) - return { count: 0 } - } - - // Insert new txs, update confirmations on existing ones - let inserted = 0 - let updated = 0 - for (const tx of txs) { - const txid = tx.txid || tx.hash || tx.txHash - if (!txid) continue - - const direction = tx.direction || (tx.value < 0 ? 'sent' : 'received') - const activityType = direction === 'sent' ? 'send' : 'receive' - const ts = tx.timestamp ? tx.timestamp * 1000 : tx.blockTime ? tx.blockTime * 1000 : Date.now() - const confirmations = typeof tx.confirmations === 'number' ? tx.confirmations : 0 - const blockHeight = tx.blockHeight || tx.block_height || tx.height || 0 - const value = tx.value != null ? String(tx.value) : undefined - const fee = tx.fee != null ? String(tx.fee) : undefined - - // Tx metadata stored in response_body - const meta = { confirmations, blockHeight, value, fee, direction } - - // PRIVACY: Skip DB writes for passphrase wallets (defense in depth — - // the RPC handler already throws before reaching here). - if (engine.isPassphraseWallet) continue - - if (apiLogTxidExists(txid)) { - // Update confirmation count on existing entry - updateApiLogTxMeta(txid, meta) - updated++ - } else { - // New tx — insert - insertApiLog({ - method: 'SCAN', - route: `history/${params.chainId}`, - timestamp: ts, - durationMs: 0, - status: 200, - appName: 'vault', - txid, - chain: chain.symbol, - activityType, - responseBody: meta, - }) - inserted++ - } - } + const result = await rebuildActivityHistory({ + wallet: engine.wallet, + scope, + chains: getAllChains(), + firmwareVersion: engine.getDeviceState().firmwareVersion, + options: { chainId: params.chainId }, + }) + const chainResult = result.chains.find(c => c.chainId === params.chainId) + if (chainResult?.error) throw new Error(chainResult.error) - console.log(`[activity] Scanned ${chain.symbol}: ${txs.length} txs, ${inserted} new, ${updated} updated`) - return { count: inserted } + console.log(`[activity] Scanned ${chain.symbol}: ${chainResult?.txs || 0} txs, ${chainResult?.inserted || 0} new, ${chainResult?.updated || 0} updated`) + return { count: chainResult?.inserted || 0 } }, dismissActivity: async (_params) => { // No-op: api_log entries are audit records, not dismissible @@ -3326,7 +4431,18 @@ const rpc = BrowserView.defineRPC({ // Incomplete: fewer cached chains than supported (e.g. app update added new chains) const fwVersion = engine.getDeviceState().firmwareVersion - const supportedChains = getAllChains().filter(c => !c.hidden && isChainSupported(c, fwVersion)) + // Mirror the user-facing dashboard filter, not the raw `!c.hidden`: + // - zcash-shielded never gets its own cache row (rendered as a token of native zcash) + // - zcash is hidden by default but visible when the privacy flag is on + // - other hidden chains stay internal-only + // Without this, freshly-enabled chains are never flagged as missing and + // the dashboard never auto-refreshes them into the cache. + const supportedChains = getAllChains().filter(c => { + if (!isChainSupported(c, fwVersion)) return false + if (c.id === 'zcash-shielded') return false + if (c.id === 'zcash') return zcashPrivacyEnabled + return !c.hidden + }) const cachedChainIds = new Set(result.balances.map(b => b.chainId)) const missingChains = supportedChains.filter(c => !cachedChainIds.has(c.id)) if (missingChains.length > 0) { @@ -3476,7 +4592,7 @@ const rpc = BrowserView.defineRPC({ emulatorInit: async (params) => { if (!emulatorEnabled) throw new Error('Emulator is disabled') const { initEmulator } = await import('./emulator') - const status = initEmulator(params?.flashName, undefined, params?.channel) + const status = initEmulator(params?.flashName) if (status.state === 'running') { // Open the emulator device window const { openEmulatorWindow } = await import('./emulator-window') @@ -3506,15 +4622,68 @@ const rpc = BrowserView.defineRPC({ const { getEmulatorStatus } = await import('./emulator') return getEmulatorStatus() }, - emulatorGetChannels: async () => { - if (!emulatorEnabled) return [] - const { getEmulatorChannels } = await import('./emulator') - return getEmulatorChannels() + emulatorInstallDylib: async (params) => { + // macOS-only: copy a user-supplied libkkemu.dylib into ~/.keepkey/emulator/ + // so subsequent emulatorInit() loads it. Auto-flips emulator_enabled + // since the user has explicitly opted in by dropping a binary. + if (process.platform !== 'darwin') throw new Error('Emulator is only available on macOS') + if (!params?.data) throw new Error('Missing dylib payload') + + const buf = Buffer.from(params.data, 'base64') + if (buf.length < 4) throw new Error('Empty dylib payload') + // Mach-O header (thin or fat). Reject anything else early so we + // don't dlopen() an arbitrary file later. + const magic = buf.readUInt32BE(0) + const MACHO_MAGIC = new Set([0xfeedfacf, 0xcffaedfe, 0xfeedface, 0xcefaedfe, 0xcafebabe, 0xbebafeca]) + if (!MACHO_MAGIC.has(magic)) { + throw new Error('File is not a Mach-O dynamic library') + } + + // Stop any running emulator before swapping the dylib — replacing + // a dlopen'd file mid-flight is undefined behavior on macOS. + const { getEmulatorStatus, stopEmulator, getDylibPath } = await import('./emulator') + if (getEmulatorStatus().state === 'running') { + const { closeEmulatorWindow } = await import('./emulator-window') + closeEmulatorWindow() + engine.disconnectEmulator() + stopEmulator() + } + + // Write to a temp file then atomically rename so a partial copy + // can never leave a half-written dylib in place. + const { writeFileSync, renameSync, mkdirSync, statSync } = await import('fs') + const { dirname } = await import('path') + const finalPath = getDylibPath() + const dir = dirname(finalPath) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = `${finalPath}.tmp-${Date.now()}` + writeFileSync(tmp, buf, { mode: 0o600 }) + renameSync(tmp, finalPath) + const size = statSync(finalPath).size + console.log(`[emulator] Installed dylib at ${finalPath} (${size} bytes)`) + + // Auto-enable emulator setting — dropping a dylib is an explicit + // opt-in to dev features. + if (!emulatorEnabled) { + emulatorEnabled = true + setSetting('emulator_enabled', '1') + console.log('[settings] Emulator enabled by dylib install') + } + return { path: finalPath, size, emulatorEnabled } }, emulatorDeleteFlash: async (params) => { if (!emulatorEnabled) throw new Error('Emulator is disabled') const { deleteFlash, getEmulatorStatus, getActiveFlashName, stopEmulator } = await import('./emulator') const { deleteMnemonic } = await import('./emulator-keychain') + const { deleteEmulatorWalletMeta, getAllEmulatorWalletMeta, deleteDeviceSnapshot } = await import('./db') + + // Look up the deviceId BEFORE deleting metadata so we can purge + // all keyed-by-deviceId data (balances, cached_pubkeys, reports, + // device_snapshot) — the same set physical forgetDevice purges. + // Without this, deleting an emulator wallet leaves stale balance + // + xpub cache + report rows on disk keyed by an emulator + // deviceId that no longer maps to anything. + const meta = getAllEmulatorWalletMeta().find(m => m.name === params.name) // If deleting the active wallet, stop it first so shutdown // doesn't re-save the flash we're about to delete @@ -3527,30 +4696,39 @@ const rpc = BrowserView.defineRPC({ deleteFlash(params.name) deleteMnemonic(params.name) + deleteEmulatorWalletMeta(params.name) + if (meta?.deviceId) deleteDeviceSnapshot(meta.deviceId) return getEmulatorStatus() }, emulatorListWallets: async () => { if (!emulatorEnabled) return [] const { listFlashImages, hasMnemonic } = await import('./emulator-keychain') const { getActiveFlashName, getEmulatorStatus } = await import('./emulator') + const { getAllEmulatorWalletMeta } = await import('./db') const status = getEmulatorStatus() const activeFlash = status.state === 'running' ? getActiveFlashName() : null - return listFlashImages().map(name => ({ - name, - hasMnemonic: hasMnemonic(name), - isActive: name === activeFlash, - })) + const metaByName = new Map(getAllEmulatorWalletMeta().map(m => [m.name, m])) + return listFlashImages().map(name => { + const meta = metaByName.get(name) + return { + name, + hasMnemonic: hasMnemonic(name), + isActive: name === activeFlash, + label: meta?.label || undefined, + firmwareVersion: meta?.firmwareVersion || undefined, + channel: meta?.channel || undefined, + deviceId: meta?.deviceId || undefined, + totalUsd: meta?.totalUsd ?? 0, + } + }) }, emulatorImportWallet: async (params) => { if (!emulatorEnabled) throw new Error('Emulator is disabled') - // Sanitize wallet name — prevent path traversal and invisible names - const name = params.name.trim() - if (!name || name.length > 64) throw new Error('Wallet name must be 1-64 characters') - if (/[\/\\]/.test(name)) throw new Error('Wallet name cannot contain path separators') - if (name.includes('..')) throw new Error('Wallet name cannot contain ".."') - if (name.includes('\0')) throw new Error('Wallet name cannot contain null bytes') - if (name.includes('.mnemonic.')) throw new Error('Wallet name cannot contain ".mnemonic."') - params = { ...params, name } + // Wallet name validation lives in emulator-keychain.validateFlashName + // (called by every path builder) — call here too so we surface the + // error before doing any work. + const { validateFlashName } = await import('./emulator-keychain') + params = { ...params, name: validateFlashName(params.name) } const { stopEmulator, initEmulator, getEmulatorStatus, flushRingBuffers, getActiveFlashName } = await import('./emulator') const { saveMnemonic, deleteMnemonic } = await import('./emulator-keychain') @@ -3569,7 +4747,7 @@ const rpc = BrowserView.defineRPC({ } // Init with the new flash name + channel (creates flash file on disk) - const status = initEmulator(params.name, undefined, params.channel) + const status = initEmulator(params.name) if (status.state !== 'running') return status try { @@ -3601,26 +4779,35 @@ const rpc = BrowserView.defineRPC({ flushRingBuffers() await engine.connectEmulator() - // Verify the firmware holds the mnemonic via DebugLink - const actualMnemonic = await engine.getEmulatorMnemonic() - if (!actualMnemonic || actualMnemonic.trim() !== params.mnemonic.trim()) { - throw new Error('Seed verification failed — firmware mnemonic does not match imported seed') + // Verify the firmware holds the mnemonic via DebugLink (3s race + // — a stuck read is a verification failure, not silently OK). + const verifyResult = await raceVerifyMnemonic(params.mnemonic) + if (!verifyResult.ok) { + throw new Error(`Seed verification failed — ${verifyResult.reason}`) } // Only persist mnemonic AFTER seed is verified on device saveMnemonic(params.name, params.mnemonic) return getEmulatorStatus() } catch (err) { - // Rollback: stop the failed emulator and clean up the orphaned flash + // Rollback: stop the failed emulator and clean up the orphaned + // flash + mnemonic + emulator_wallet metadata + device cache. + // connectEmulator persists metadata as part of its success path, + // so a failed verify can leave a metadata row + cached balances + // keyed to a wallet that no longer exists. console.error('[Emulator] Import failed, rolling back:', (err as Error).message) + const failedDeviceId = engine.cachedFeatures?.deviceId const { closeEmulatorWindow } = await import('./emulator-window') + const { deleteEmulatorWalletMeta, deleteDeviceSnapshot } = await import('./db') closeEmulatorWindow() engine.disconnectEmulator() stopEmulator() - deleteFlash(params.name) - deleteMnemonic(params.name) + try { deleteFlash(params.name) } catch {} + try { deleteMnemonic(params.name) } catch {} + try { deleteEmulatorWalletMeta(params.name) } catch {} + if (failedDeviceId) { try { deleteDeviceSnapshot(failedDeviceId) } catch {} } - // Restore previous emulator if one was running (channel preserved by selectedChannel) + // Restore previous emulator if one was running if (prevFlashName) { const restored = initEmulator(prevFlashName) if (restored.state === 'running') { @@ -3645,7 +4832,7 @@ const rpc = BrowserView.defineRPC({ } // Init with the requested flash name + channel - const status = initEmulator(params.name, undefined, params.channel) + const status = initEmulator(params.name) if (status.state !== 'running') return status // Open window + connect engine (auto-reloads saved mnemonic) @@ -3669,70 +4856,103 @@ const rpc = BrowserView.defineRPC({ console.log(`[Emulator] Generated ${wc}-word mnemonic`) // Save mnemonic FIRST — connectEmulator's auto-reload uses it - const { saveMnemonic } = await import('./emulator-keychain') - const { getActiveFlashName } = await import('./emulator') - saveMnemonic(getActiveFlashName(), mnemonic) + const { saveMnemonic, deleteMnemonic } = await import('./emulator-keychain') + const { getActiveFlashName, deleteFlash, stopEmulator } = await import('./emulator') + const flashName = getActiveFlashName() + saveMnemonic(flashName, mnemonic) - // Wipe first if already initialized — firmware rejects loadDevice otherwise - if (engine.cachedFeatures?.initialized) { - console.log('[Emulator] Already initialized — wiping before create') - await emuConfirmOp(() => engine.wallet!.wipe()) - const { flushRingBuffers } = await import('./emulator') - flushRingBuffers() - await engine.connectEmulator() - } + try { + // Wipe first if already initialized — firmware rejects loadDevice otherwise + if (engine.cachedFeatures?.initialized) { + console.log('[Emulator] Already initialized — wiping before create') + await emuConfirmOp(() => engine.wallet!.wipe()) + const { flushRingBuffers } = await import('./emulator') + flushRingBuffers() + await engine.connectEmulator() + } - // If auto-reload already initialized with the new seed, skip manual load - if (!engine.cachedFeatures?.initialized) { - await emuConfirmOp(() => (engine.wallet as any).loadDevice({ - mnemonic, pin: false, passphrase: false, skipChecksum: false, - })) - console.log('[Emulator] loadDevice complete') - } else { - console.log('[Emulator] Device initialized by auto-reload — skipping manual loadDevice') - } + // If auto-reload already initialized with the new seed, skip manual load + if (!engine.cachedFeatures?.initialized) { + await emuConfirmOp(() => (engine.wallet as any).loadDevice({ + mnemonic, pin: false, passphrase: false, skipChecksum: false, + })) + console.log('[Emulator] loadDevice complete') + } else { + console.log('[Emulator] Device initialized by auto-reload — skipping manual loadDevice') + } - // Auto-set label with EMU prefix - try { - await emuConfirmOp(() => engine.applySettings({ label: 'EMU KeepKey', skipRefresh: true })) - console.log('[Emulator] Label set') - } catch (e: any) { - console.warn('[Emulator] Label set failed (non-critical):', e?.message) - } + // Auto-set label with EMU prefix + try { + await emuConfirmOp(() => engine.applySettings({ label: 'EMU KeepKey', skipRefresh: true })) + console.log('[Emulator] Label set') + } catch (e: any) { + console.warn('[Emulator] Label set failed (non-critical):', e?.message) + } - // Drain stale data + reconnect for clean transport - const { flushRingBuffers } = await import('./emulator') - flushRingBuffers() - await engine.connectEmulator() + // Drain stale data + reconnect for clean transport + const { flushRingBuffers } = await import('./emulator') + flushRingBuffers() + await engine.connectEmulator() - // Verify the firmware actually holds the mnemonic we generated - const actualMnemonic = await engine.getEmulatorMnemonic() - if (!actualMnemonic) { - console.error('[Emulator] SEED VERIFY FAIL — firmware returned no mnemonic via DebugLink') - } else if (actualMnemonic.trim() !== mnemonic.trim()) { - console.error('[Emulator] SEED VERIFY FAIL — firmware mnemonic does NOT match generated seed') - console.error('[Emulator] expected first word: %s', mnemonic.trim().split(/\s+/)[0]) - console.error('[Emulator] actual first word: %s', actualMnemonic.trim().split(/\s+/)[0]) - } else { + // Verify the firmware actually holds the mnemonic we generated. + // MUST be fatal — a successful return tells the wizard to show + // the user a seed they should write down. If the firmware doesn't + // hold this seed, the user backs up a recovery phrase that won't + // recover the wallet. raceVerifyMnemonic caps at 3s so a stuck + // DebugLink read doesn't hang the RPC — timeout = failure. + const verifyResult = await raceVerifyMnemonic(mnemonic) + if (!verifyResult.ok) { + throw new Error(`Seed verification failed — ${verifyResult.reason}`) + } console.log('[Emulator] SEED VERIFY OK — firmware mnemonic matches generated seed') - } - // Show seed words on emulator device window (NOT the main UI) - const { displaySeedWords, isEmulatorWindowOpen } = await import('./emulator-window') - if (isEmulatorWindowOpen()) { + // Show seed words on emulator device window (NOT the main UI). + // displaySeedWords throws if the window can't be brought up OR + // if the user closes the window without acking — so we never + // tell the wizard "seedDisplayed: true" when the user didn't + // actually see (and ack) the words. + const { displaySeedWords } = await import('./emulator-window') await displaySeedWords(mnemonic) - } - // Return success flag only — mnemonic stays on the "device" - return { seedDisplayed: true } + return { seedDisplayed: true } + } catch (err) { + // Rollback: a saved mnemonic + initialized firmware would let + // connectEmulator's auto-reload silently resurrect the wallet + // next session — meaning the user could end up using a wallet + // that the wizard told them failed to create and which they + // were never given the chance to back up. Also drop the + // emulator_wallet metadata + cached device data that + // connectEmulator persisted en route to the failed verify. + console.error('[Emulator] create-wallet failed, rolling back:', (err as Error).message) + const failedDeviceId = engine.cachedFeatures?.deviceId + try { + const { closeEmulatorWindow } = await import('./emulator-window') + closeEmulatorWindow() + } catch {} + try { engine.disconnectEmulator() } catch {} + try { stopEmulator() } catch {} + try { deleteMnemonic(flashName) } catch {} + try { deleteFlash(flashName) } catch {} + try { + const { deleteEmulatorWalletMeta, deleteDeviceSnapshot } = await import('./db') + deleteEmulatorWalletMeta(flashName) + if (failedDeviceId) deleteDeviceSnapshot(failedDeviceId) + } catch {} + throw err + } }, // ── WalletConnect (native v2) ──────────────────────────── wcPair: async (params) => { + console.log('[wcPair] called with URI prefix:', params.uri?.slice(0, 24), 'len:', params.uri?.length) if (!walletConnectEnabled) throw new Error('WalletConnect is disabled') if (!engine.wallet) throw new Error('No device connected') + // EVM derivation is now lazy and namespace-scoped — handled by + // ensureEvmAddressInfo() inside onSessionProposal, only when the + // dApp actually requests eip155. const wc = getOrCreateWcManager() await wc.pair(params.uri) + console.log('[wcPair] pair() returned (session_proposal handled async via listener)') }, wcGetSessions: async () => { if (!wcManager) return [] @@ -3742,6 +4962,84 @@ const rpc = BrowserView.defineRPC({ if (!wcManager) return await wcManager.disconnectSession(params.topic) }, + wcApprovePair: async (params) => { + if (!wcManager) return + wcManager.approvePair(params.id) + }, + wcRejectPair: async (params) => { + if (!wcManager) return + wcManager.rejectPair(params.id) + }, + wcScanScreen: async () => { + if (process.platform !== 'darwin') { + throw new Error('Screen QR scan is only supported on macOS') + } + + const openScreenCaptureSettings = () => { + try { + Bun.spawn(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture']) + } catch { /* best effort */ } + } + + // CGPreflight is only a hint here. In packaged Electrobun builds this + // code runs from the embedded Bun helper, while TCC may show the outer + // app bundle in System Settings. Blocking solely on preflight creates + // false "missing permission" errors after the user has toggled Vault on. + let preflightGranted = false + let promptShown = false + let requestResult: number | null = null + try { + const { dlopen, FFIType } = require('bun:ffi') + const cgPath = '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics' + const cg = dlopen(cgPath, { + CGPreflightScreenCaptureAccess: { args: [], returns: FFIType.u8 }, + CGRequestScreenCaptureAccess: { args: [], returns: FFIType.u8 }, + }) + const pre = cg.symbols.CGPreflightScreenCaptureAccess() as unknown as number + preflightGranted = pre !== 0 + console.log('[wcScanScreen] CGPreflight =', pre, 'granted:', preflightGranted) + if (!preflightGranted) { + const req = cg.symbols.CGRequestScreenCaptureAccess() as unknown as number + requestResult = req + console.log('[wcScanScreen] CGRequest returned', req, '— TCC prompt should be visible now') + promptShown = true + } + } catch (e: any) { + console.warn('[wcScanScreen] CG FFI failed:', e.message, '— falling through to screencapture') + } + + const path = `/tmp/kk-wc-scan-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.png` + // -i interactive selection, -x silent, -t png format. + const proc = Bun.spawn(['screencapture', '-i', '-x', '-t', 'png', path], { + stdout: 'ignore', + stderr: 'pipe', + }) + const stderrText = (await new Response(proc.stderr).text()).trim() + const exitCode = await proc.exited + const file = Bun.file(path) + const exists = await file.exists() + + const permissionFailure = exitCode !== 0 && ( + /could not create image|not authori[sz]ed|screen recording|permission|denied|privacy/i.test(stderrText) || + (!exists && requestResult === 0) + ) + if (permissionFailure) { + console.log('[wcScanScreen] screencapture failed; permission likely missing or stale', { + exitCode, + stderrText, + preflightGranted, + promptShown, + requestResult, + }) + openScreenCaptureSettings() + throw new Error(promptShown ? 'SCREEN_RECORDING_PERMISSION_PROMPTED' : 'SCREEN_RECORDING_PERMISSION_REQUIRED') + } + if (!exists) return null // user canceled (Esc / clicked away) + const bytes = await file.arrayBuffer() + try { fs.unlinkSync(path) } catch { /* best effort */ } + if (bytes.byteLength === 0) return null + return { pngBase64: Buffer.from(bytes).toString('base64') } + }, // ── Utility ────────────────────────────────────────────── openUrl: async (params) => { @@ -3769,6 +5067,62 @@ const rpc = BrowserView.defineRPC({ pendingDeepLinkUri = null }, + // ── Linux: install udev rules for KeepKey ──────────────── + // Writes /etc/udev/rules.d/51-keepkey.rules via pkexec, then + // reloads udev and re-triggers detection. Polkit-aware desktop + // sessions show a graphical password prompt automatically. + // + // Uses TAG+="uaccess" (systemd-logind) instead of GROUP="plugdev" + // so access is granted to the active seat user without requiring + // the user be in a specific group — works out-of-the-box on every + // modern systemd distro (Ubuntu 22.04+, Fedora, Arch, Debian 12+). + installLinuxUdevRules: async () => { + if (process.platform !== 'linux') { + return { success: false, error: 'Only available on Linux' } + } + const RULE_PATH = '/etc/udev/rules.d/51-keepkey.rules' + const RULE_BODY = `# KeepKey hardware wallet — installed by KeepKey Vault +# Grants the active seat user raw USB + hidraw access to the device. +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2b24", ATTRS{idProduct}=="0001", TAG+="uaccess" +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2b24", ATTRS{idProduct}=="0002", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", TAG+="uaccess" +` + // Heredoc with a quoted sentinel ('KKEOF') prevents the shell + // from interpolating any character in the rule body. + const script = `set -e +cat > ${RULE_PATH} <<'KKEOF' +${RULE_BODY}KKEOF +chmod 0644 ${RULE_PATH} +udevadm control --reload-rules +udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || udevadm trigger +` + try { + const proc = Bun.spawn(['pkexec', '/bin/sh', '-c', script], { + stdout: 'pipe', + stderr: 'pipe', + }) + const exitCode = await proc.exited + if (exitCode === 0) { + console.log('[udev] Installed KeepKey udev rules — re-syncing device state') + // Give udev a moment, then re-probe so the UI updates without manual retry. + setTimeout(() => engine.syncState().catch(() => {}), 500) + return { success: true } + } + // pkexec exit codes: 126 = auth dismissed, 127 = pkexec/command not found, + // other = command failed. stderr usually carries the cause. + const stderr = await new Response(proc.stderr as any).text() + if (exitCode === 127) { + return { success: false, error: 'pkexec is not installed. Install policykit-1 (Debian/Ubuntu) or polkit (Fedora/Arch), or copy the rule manually — see the link below.' } + } + if (exitCode === 126) { + return { success: false, error: 'Authentication was cancelled.' } + } + return { success: false, error: stderr.trim() || `pkexec exited ${exitCode}` } + } catch (err: any) { + return { success: false, error: err?.message || String(err) } + } + }, + // ── App Updates ────────────────────────────────────────── checkForUpdate: async () => { const localVer = await Updater.localInfo.version() @@ -3843,6 +5197,16 @@ const rpc = BrowserView.defineRPC({ version: await Updater.localInfo.version(), channel: await Updater.localInfo.channel(), }), + // ── REST API UI-active gate ─────────────────────────────── + // The WebView calls uiSetActive(true) on mount and uiSetActive(false) + // before unload, plus a periodic heartbeat. Without a fresh heartbeat, + // rest-api.ts refuses to serve pubkeys/addresses to 3rd-party clients. + uiSetActive: async ({ active, viewDeviceId }) => { + setUiActive(Boolean(active), viewDeviceId ?? null) + }, + uiHeartbeat: async (params) => { + uiHeartbeat((params as any)?.viewDeviceId ?? null) + }, // ── Window controls (for custom titlebar) ───────────────── windowClose: async () => { _mainWindow?.close() }, windowMinimize: async () => { _mainWindow?.minimize() }, @@ -3864,44 +5228,48 @@ sendFatal = (source, err) => { try { rpc.send['fatal']({ source, message, stack }) } catch { /* webview not ready */ } } -// Initialize swap tracker with typed RPC message sender (only if swaps feature is ON) -if (swapsEnabled) { - import('./swap-tracker').then(async ({ initSwapTracker }) => { - await initSwapTracker((msg: string, data: any) => { - try { - if (msg === 'swap-update') rpc.send['swap-update'](data) - else if (msg === 'swap-complete') rpc.send['swap-complete'](data) - else console.error(`[swap-tracker] Unknown message: ${msg}`) - } catch (e: any) { - console.warn(`[swap-tracker] Failed to send '${msg}':`, e.message) - } - }) - }).catch((e) => { - console.error('[swap-tracker] Failed to initialize swap tracker (swaps will be unavailable):', e.message || e) - }) -} else { - console.log('[swap-tracker] Swap feature flag is OFF — tracker not initialized') -} +// Tracker init moved into deferredInit() so it runs after DB ready + settings loaded. + +// Hoisted above the state-change listener: engine.start() (later in the file) +// can synchronously emit 'ready', and the listener's deep-link replay path +// would TDZ on this binding if it were declared near the URL handler. +let pendingDeepLinkUri: string | null = null // Push engine events to WebView engine.on('state-change', (state) => { try { rpc.send['device-state'](state) } catch { /* webview not ready yet */ } + // Replay any WC deep link that was queued while no device was connected. + // Without this, a deep link delivered before the device was ready would + // sit in pendingDeepLinkUri until the next mount of WalletConnectPanel. + if (state.state === 'ready' && pendingDeepLinkUri && walletConnectEnabled) { + const uri = pendingDeepLinkUri + pendingDeepLinkUri = null + try { rpc.send['wc-deep-link-pair']({ uri }) } + catch { pendingDeepLinkUri = uri /* webview not ready — keep queued */ } + } // Auto-disable advanced features if firmware doesn't support them if (state.state === 'ready') { const fw = state.firmwareVersion - if (bip85Enabled && (!fw || versionCompare(fw, '7.15.0') < 0)) { + if (bip85Enabled && (!fw || versionCompare(fw, '7.16.0') < 0)) { bip85Enabled = false setSetting('bip85_enabled', '0') - console.log(`[settings] BIP-85 auto-disabled — firmware ${fw || 'unknown'} < 7.15.0`) + console.log(`[settings] BIP-85 auto-disabled — firmware ${fw || 'unknown'} < 7.16.0`) } - if (zcashPrivacyEnabled && (!fw || versionCompare(fw, '7.14.0') < 0)) { + if (zcashPrivacyEnabled && (!fw || versionCompare(fw, '7.15.0') < 0)) { zcashPrivacyEnabled = false setSetting('zcash_privacy_enabled', '0') stopSidecar() - console.log(`[settings] Zcash privacy auto-disabled — firmware ${fw || 'unknown'} < 7.14.0`) + console.log(`[settings] Zcash privacy auto-disabled — firmware ${fw || 'unknown'} < 7.15.0`) } } - if (state.state === 'disconnected') { btcAccounts.reset(); evmAddresses.reset() } + if (state.state === 'disconnected') { + btcAccounts.reset() + evmAddresses.reset() + console.log('[Vault] Device disconnected: cleared in-memory account managers') + } + if (state.state === 'disconnected' || state.state === 'needs_passphrase') { + pendingScopedApiLogs.splice(0) + } // When entering passphrase mode, the seed is about to change — clear all // cached addresses so they get re-derived from the new passphrase seed. if (state.state === 'needs_passphrase') { @@ -3918,6 +5286,10 @@ engine.on('state-change', (state) => { console.log('[Vault] Passphrase mode: reset in-memory address managers — will re-derive after passphrase entry') } }) +engine.on('wallet-scope-ready', ({ deviceId, seedAddress }) => { + console.log(`[Vault] Wallet scope ready on ${deviceId}: ${seedAddress?.slice(0, 10)}...`) + flushPendingScopedApiLogs() +}) // Seed changed — different mnemonic loaded on the same hardware. // Reset in-memory address managers so they re-derive from the new seed. // Don't wipe DB — let the fresh Pioneer fetch naturally overwrite stale entries. @@ -3925,6 +5297,18 @@ engine.on('seed-changed', ({ deviceId, oldAddress, newAddress }) => { console.warn(`[Vault] SEED CHANGED on ${deviceId}: ${oldAddress?.slice(0, 10)} → ${newAddress?.slice(0, 10)}`) btcAccounts.reset() evmAddresses.reset() + // Zcash sidecar holds a per-seed FVK + scanned notes both in memory and in + // ~/.keepkey/zcash_wallet.db. After a seed change those are wrong for the + // new wallet — but `hasFvkLoaded()` would still return true (cache is + // populated for the old seed), so `ensureFvkLoaded()` would short-circuit + // and the next send would build a tx against the wrong FVK. Stop the + // sidecar (clears in-memory cache + verification flag), wipe the on-disk + // DB so the next start boots without auto-loading the stale FVK, and let + // the next access re-init from the device. + stopSidecar() + wipeSidecarWalletDb() + zcashVerifiedThisSession = false + zcashBackgroundVerifyInFlight = false // Clear stale DB caches — old seed's pubkeys and balances are wrong for the new seed if (deviceId) { clearCachedPubkeys(deviceId) @@ -3953,6 +5337,9 @@ engine.on('character-request', (req) => { engine.on('passphrase-request', () => { try { rpc.send['passphrase-request']({}) } catch { /* webview not ready yet */ } }) +engine.on('button-request', () => { + try { rpc.send['device-button-request']({}) } catch { /* webview not ready yet */ } +}) engine.on('recovery-error', (err) => { try { rpc.send['recovery-error'](err) } catch { /* webview not ready yet */ } }) @@ -4195,13 +5582,41 @@ Updater.localInfo.channel().then(ch => { } }) +function findMacAppBundlePath(): string | null { + const starts = [ + process.execPath, + process.argv[1], + import.meta.dir, + process.cwd(), + ].filter((p): p is string => !!p) + + for (const start of starts) { + let current = start + try { + if (fs.existsSync(current) && fs.statSync(current).isFile()) current = path.dirname(current) + } catch { /* best effort */ } + + for (let i = 0; i < 10; i++) { + if (current.endsWith('.app') && fs.existsSync(path.join(current, 'Contents', 'Info.plist'))) { + return current + } + const next = path.dirname(current) + if (next === current) break + current = next + } + } + + return null +} + // ── Force keepkey:// protocol registration (override old keepkey-desktop) ── if (process.platform === 'darwin') { // Re-register this app as the handler for keepkey:// via Launch Services. // When both keepkey-desktop (Electron) and keepkey-vault (Electrobun) are // installed, the last one to register wins. We force it on every launch. try { - const appPath = path.resolve(import.meta.dir, '..', '..', '..') + const appPath = findMacAppBundlePath() + if (!appPath) throw new Error('Could not locate enclosing .app bundle') const lsregister = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister' // -f forces re-registration of the app's URL schemes from its Info.plist, // ensuring keepkey:// points to this vault instead of the old Electron desktop app. @@ -4216,7 +5631,8 @@ if (process.platform === 'darwin') { } // ── keepkey:// and keepkey-vault:// Protocol Handler ────────────────── -let pendingDeepLinkUri: string | null = null +// (declared above; moved earlier to avoid TDZ access from the state-change +// listener that replays queued deep links on device-ready.) function getWalletConnectUri(inputUri: string): string | undefined { const uri = inputUri @@ -4233,13 +5649,18 @@ function handleKeepKeyUrl(url: string) { const wcUri = getWalletConnectUri(url) if (wcUri) { if (walletConnectEnabled && engine.wallet) { - // Native WC v2 — pair directly in the backend - const wc = getOrCreateWcManager() - wc.pair(wcUri).catch(e => { - console.error('[WC] Pair failed:', e.message) - // Store for retry via getPendingDeepLink when device becomes ready + // Hand the URI to the panel so it mounts *before* the WC + // session_proposal arrives. The pair-approval modal lives inside + // WalletConnectPanel; pairing directly from here while the panel + // is closed would let the modal render invisibly and the proposal + // would silently time out at 120s. + try { + rpc.send['wc-deep-link-pair']({ uri: wcUri }) + pendingDeepLinkUri = null + } catch { + // Webview not ready — let the cold-start path pick it up. pendingDeepLinkUri = wcUri - }) + } } else if (walletConnectEnabled && !engine.wallet) { // Device not ready — queue for later pendingDeepLinkUri = wcUri @@ -4293,6 +5714,9 @@ function cleanupAndQuit() { // Fire-and-forget — relay WebSocket close is best-effort within the 5s force-exit. try { wcManager?.destroy().catch((e: any) => console.warn('[cleanup] WC destroy:', e.message)) } catch {} stopSidecar() + // Flip REST UI-active flag off + flush pubkey/address caches so a late + // in-flight request during the 5s force-exit window can't leak state. + try { setUiActive(false, null) } catch {} engine.stop() restServer?.stop() Utils.quit() diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index b4ef368b..8e0d5cbc 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -8,6 +8,7 @@ import { CHAINS, isChainSupported } from '../shared/chains' import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, buildShieldedTx, finalizeShieldedTx, broadcastShieldedTx, + ensureFvkLoaded, displayOrchardAddressOnDevice, } from './txbuilder/zcash-shielded' import { isSidecarReady } from './zcash-sidecar' import { readFileSync } from 'fs' @@ -15,8 +16,26 @@ import { join } from 'path' import * as S from './schemas' import { parseRequest, validateResponse } from './validate' import { handleV2DataRoute } from './rest-pioneer' +import { handleSwapRoute } from './rest-swap' import { handleSweepRoute } from './rest-sweep' -import { getSetting } from './db' +import { getSetting, findApiLogs, getApiLogById, getRecentActivityFromLog, getSwapHistory, getSwapHistoryByTxid, getSwapHistoryStats, getCachedBalances, getCachedPubkeys, getAllTokenVisibility, getTokensByVisibility, setTokenVisibility, removeTokenVisibility } from './db' +import { detectSpamToken, categorizeTokens } from '../shared/spamFilter' +import { rebuildActivityHistory, type ActivityHistoryRebuildOptions } from './activity-history' +import type { SwapTrackingStatus } from '../shared/types' +import { parseSolanaTx, SolanaTxParseError, solanaMessageSlice } from './solana-tx' +import { buildSolanaDecodedInfo } from './solana-clearsign' +import { buildSolanaMessageDecodedInfo } from './solana-message-preview' +import { createRpcAltFetcher, DEFAULT_SOLANA_RPC_ENDPOINT } from './solana-alt' +import { + buildTonTransfer, + assembleTonSignedBoc, + computeTonBodyHash, + getTonSeqno, + getTonWalletState, + broadcastTonBoc, + type TonBuildResult, +} from './txbuilder/ton' +import { usb } from 'usb' export interface EmuSigningDetails { operation: string @@ -35,6 +54,14 @@ export interface RestApiCallbacks { getVersion: () => string /** Wrap a signing/display op for the emulator (pre-writes confirmations, interactive approve) */ emuSigningOp?: (fn: () => Promise, details: EmuSigningDetails) => Promise + /** Read the latest SwapDialog UI state mirror (set by /api/v2/swap/state) */ + getSwapUiState?: () => { state: import('../shared/types').SwapUiState; updatedAt: number } + /** Push a swap-cmd to the WebView (used by /api/v2/swap/{open,set,requote,close}) */ + sendSwapCmd?: (cmd: import('../shared/types').SwapUiCommand) => void + /** Returns initialized Pioneer client (for debug endpoints) */ + getPioneer?: () => Promise + /** Returns the active Pioneer API base URL */ + getPioneerApiBase?: () => string } function corsHeaders(_req?: Request): Record { @@ -54,6 +81,71 @@ function requireWallet(engine: EngineController) { return engine.wallet } +const KEEPKEY_VENDOR_ID = 0x2B24 + +function usbIdHex(value: number | null | undefined): string | null { + if (typeof value !== 'number') return null + return `0x${value.toString(16).padStart(4, '0')}` +} + +function listUsbDevicesForAdmin() { + return usb.getDeviceList().map((device: any) => { + const descriptor = device.deviceDescriptor || {} + const vendorId = typeof descriptor.idVendor === 'number' ? descriptor.idVendor : null + const productId = typeof descriptor.idProduct === 'number' ? descriptor.idProduct : null + return { + busNumber: typeof device.busNumber === 'number' ? device.busNumber : null, + deviceAddress: typeof device.deviceAddress === 'number' ? device.deviceAddress : null, + portNumbers: Array.isArray(device.portNumbers) ? device.portNumbers : [], + vendorId, + vendorIdHex: usbIdHex(vendorId), + productId, + productIdHex: usbIdHex(productId), + deviceClass: typeof descriptor.bDeviceClass === 'number' ? descriptor.bDeviceClass : null, + deviceSubClass: typeof descriptor.bDeviceSubClass === 'number' ? descriptor.bDeviceSubClass : null, + deviceProtocol: typeof descriptor.bDeviceProtocol === 'number' ? descriptor.bDeviceProtocol : null, + usbVersion: typeof descriptor.bcdUSB === 'number' ? usbIdHex(descriptor.bcdUSB) : null, + deviceVersion: typeof descriptor.bcdDevice === 'number' ? usbIdHex(descriptor.bcdDevice) : null, + manufacturerIndex: typeof descriptor.iManufacturer === 'number' ? descriptor.iManufacturer : null, + productIndex: typeof descriptor.iProduct === 'number' ? descriptor.iProduct : null, + serialNumberIndex: typeof descriptor.iSerialNumber === 'number' ? descriptor.iSerialNumber : null, + isKeepKey: vendorId === KEEPKEY_VENDOR_ID, + } + }) +} + +/** + * Parse a hex string into a Buffer with explicit validation. + * + * `Buffer.from(str, 'hex')` silently truncates on the first non-hex char + * or odd length, which surfaces downstream as "wrong-length signature" + * errors that don't point at the actual bug. This helper rejects bad + * input up front with a clear 400. + */ +function parseHex(input: string, label: string, expectedBytes?: number): Buffer { + const stripped = input.replace(/^0x/i, '') + if (!/^[0-9a-fA-F]*$/.test(stripped)) { + throw new HttpError(400, `${label}: invalid hex (non-hex characters)`) + } + if (stripped.length % 2 !== 0) { + throw new HttpError(400, `${label}: invalid hex (odd-length string, must be even)`) + } + if (expectedBytes !== undefined && stripped.length !== expectedBytes * 2) { + throw new HttpError(400, `${label}: expected ${expectedBytes} bytes, got ${stripped.length / 2}`) + } + return Buffer.from(stripped, 'hex') +} + +/** Decode a `message` body field per `is_text` (default UTF-8, false = hex bytes). */ +function decodeMessageBody(message: string, isText: boolean | undefined, label: string): Buffer { + return isText === false ? parseHex(message, `${label}.message (is_text=false)`) : Buffer.from(message, 'utf8') +} + +/** Single-shot Uint8Array → hex serializer used by every signing handler. */ +function toHex(value: Uint8Array | string): string { + return value instanceof Uint8Array ? Buffer.from(value).toString('hex') : value +} + /** SLIP44 coin type → KeepKey firmware coin name (must match firmware coin table) */ const SLIP44_TO_COIN: Record = { 0: 'Bitcoin', 2: 'Litecoin', 3: 'Dogecoin', 5: 'Dash', @@ -68,6 +160,23 @@ const TICKER_TO_COIN: Record = { BCH: 'BitcoinCash', TRX: 'Tron', SOL: 'Solana', TON: 'Ton', RUNE: 'Rune', } +const DEFAULT_SOLANA_ADDRESS_N = [0x8000002C, 0x800001F5, 0x80000000, 0x80000000] + +function pickAddressNList(body: any, fallback: number[]): number[] { + return Array.isArray(body?.addressNList) + ? body.addressNList + : Array.isArray(body?.address_n) + ? body.address_n + : fallback +} + +function formatAddressNPath(addressNList: number[]): string { + return 'm/' + addressNList.map((n) => { + const hardened = n >= 0x80000000 + return `${hardened ? n - 0x80000000 : n}${hardened ? "'" : ''}` + }).join('/') +} + // ── Features cache (10s TTL, matches keepkey-desktop) ────────────────── let featuresCache: { timestamp: number; data: any } | null = null const FEATURES_TTL_MS = 10_000 @@ -149,6 +258,54 @@ function evictOldest(cache: Map, count: number) { } } +/** Cache key scoped by device_id — prevents cross-device pubkey leakage. + * deviceId is read at call time from engine; if no device is connected, + * we still prefix with `none:` so orphan entries can be flushed together. */ +function scopedKey(engine: EngineController, prefix: string, body: unknown): string { + const deviceId = engine.getDeviceState().deviceId || 'none' + return `${deviceId}:${prefix}:${JSON.stringify(body)}` +} + +/** Clear every pubkey cache entry. Call on device disconnect / device swap. */ +export function clearPubkeyCache() { + pubkeyCache.clear() +} + +/** Clear every address cache entry. Call on device disconnect / device swap. */ +export function clearAddressCache() { + addressCache.clear() +} + +// ── UI lifecycle signal ──────────────────────────────────────────────── +// The WebView signals `setUiActive(true)` on mount and `setUiActive(false)` +// on unload. We use the active→inactive transition to flush caches so a +// later re-open cannot serve entries the user might assume were re-derived. +// Note: access control for these endpoints is handled by `auth.requireAuth` +// (paired-app API key) plus per-device cache scoping via `scopedKey`; we do +// NOT gate on UI visibility, because paired apps (e.g. the browser extension) +// must be able to refresh pubkeys after a device reconnect even when the +// Vault window is closed. +let uiActive = false + +/** Called from RPC handler when the WebView signals its state. */ +export function setUiActive(active: boolean, _viewDeviceId: string | null = null) { + const wasActive = uiActive + uiActive = active + if (!active && wasActive) { + // UI just closed — flush caches so next session can't serve stale pubkeys. + clearPubkeyCache() + clearAddressCache() + clearFeaturesCache() + } +} + +/** Called from RPC handler on periodic heartbeat from the WebView. + * Retained as a no-op so the RPC contract with the frontend stays stable; + * cache lifecycle is driven entirely by `setUiActive` transitions now. */ +export function uiHeartbeat(_viewDeviceId: string | null = null) { + // intentionally empty +} + // ── Cosmos-family amino signing helper ───────────────────────────────── async function cosmosAminoSign( wallet: any, @@ -509,7 +666,16 @@ function getSwaggerUiHtml(): string { POST/utxo/sign-transactionSign Bitcoin/UTXO tx600s POST/cosmos/sign-aminoSign Cosmos amino600s POST/solana/sign-transactionSign Solana tx600s + POST/api/zcash/shielded/display-addressDisplay device-derived Orchard UA600s POST/api/pubkeys/batchBatch public keys30s + GET/api/v1/activity/recentWallet-facing recent activity (auth) — rebuilt tx history + swaps, current wallet scope only5s + GET/api/v1/activityRaw signing/API audit log (auth) — filter by route/txid/chain/activityType/since/until5s + GET/api/v1/activity/:idSingle audit entry with full request/response bodies (auth)5s + GET/api/v1/swapsSwap history (auth) — filter by status/asset/fromDate/toDate/limit/offset5s + GET/api/v1/swaps/statsAggregate counts: total/completed/failed/refunded/pending (auth)5s + GET/api/v1/swaps/:txidSingle swap record with full fee + memo + status detail (auth)5s + GET/api/v1/swap/availability/:caipPicker classification for one CAIP-19 (debug) — assessment + provider list + reason (auth)5s + GET/api/v1/swap/discoverySearch the unified asset universe (~30k); filter by ?q=&status=&limit= (auth)5s
@@ -864,10 +1030,77 @@ const SIGNING_ROUTES = new Set([ '/mayachain/sign-amino-transfer', '/mayachain/sign-amino-deposit', ]) +/** + * Minimum-payload fingerprint per signing route. + * + * Empty-body probes (observed hitting /solana/sign-transaction etc. with + * `{}`) used to reach the approval dialog and spam the user with dialogs + * for requests that had nothing to sign. This function returns the list + * of top-level payload keys where *any* one being present indicates a + * real signing attempt. The approval gate short-circuits with 400 when + * the body contains none of them. + * + * Keys mirror the schemas in schemas.ts exactly; when a new sign route is + * added to SIGNING_ROUTES it must also be added here or it'll fall + * through as "no required fields known" → no probe gating (the handler's + * schema.parse will still reject the empty body, just one layer deeper). + * + * Route families that share a schema (all the Cosmos/Osmosis amino + * variants use CosmosAminoSignRequest — { signerAddress, signDoc }) are + * covered by a single prefix check so we don't have to enumerate every + * variant and risk missing one. + */ +function requiredSigningFields(path: string): string[] | null { + const exact: Record = { + '/eth/sign-transaction': ['to', 'data', 'value', 'nonce'], + '/eth/sign-typed-data': ['typedData'], + '/eth/sign': ['message'], + '/utxo/sign-transaction': ['inputs', 'outputs'], + '/xrp/sign-transaction': ['payment', 'sequence'], + '/solana/sign-transaction': ['raw_tx', 'rawTx'], + '/solana/sign-message': ['message'], + '/tron/sign-transaction': ['raw_tx', 'rawTx', 'to_address', 'amount'], + '/ton/sign-transaction': ['raw_tx', 'rawTx', 'to_address', 'amount'], + } + if (exact[path]) return exact[path] + // All Cosmos-family amino sign endpoints (cosmos/osmosis/thorchain/ + // mayachain delegates, swaps, LP ops, IBC transfers, etc.) use + // CosmosAminoSignRequest. + if (/^\/(cosmos|osmosis|thorchain|mayachain)\/sign-amino/.test(path)) { + return ['signerAddress', 'signDoc'] + } + return null +} + export function startRestApi(engine: EngineController, auth: AuthStore, port = 1646, callbacks?: RestApiCallbacks) { - // Invalidate features cache on device disconnect + const getWalletDbScope = (): { deviceId: string; walletId: string } | null => { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) return null + const seedId = engine.currentSeedEthAddress?.toLowerCase() + if (!seedId) return null + return { deviceId, walletId: `${deviceId}:${seedId}` } + } + + // Device-swap detection: if deviceId changes between two `ready` states, + // pubkey/address caches must be flushed or the old device's xpubs will + // leak. (lastDeviceId is also flushed on disconnect so a re-connect of the + // SAME device will repopulate from scratch.) + let lastDeviceId: string | null = null engine.on('state-change', (state) => { - if (state.state === 'disconnected') clearFeaturesCache() + const nextId = state.deviceId ?? null + if (state.state === 'disconnected') { + clearFeaturesCache() + clearPubkeyCache() + clearAddressCache() + lastDeviceId = null + return + } + if (nextId && lastDeviceId && nextId !== lastDeviceId) { + clearFeaturesCache() + clearPubkeyCache() + clearAddressCache() + } + if (nextId) lastDeviceId = nextId }) /** @@ -883,6 +1116,17 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return fn() } + /** Return 501 if firmware doesn't meet the chain's minFirmware requirement. */ + function requireChainSupport(chainId: string): Response | null { + const chain = CHAINS.find(c => c.id === chainId) + if (!chain?.minFirmware) return null + const fw = engine.getDeviceState().firmwareVersion + if (!fw || !isChainSupported(chain, fw)) { + return json({ error: `${chain.symbol} requires firmware ≥ ${chain.minFirmware} (device has ${fw ?? 'unknown'})` }, 501) + } + return null + } + /** Normalize showDisplay to boolean (undefined → false). */ function showDisplay(requested: boolean | undefined): boolean { return requested ?? false @@ -922,30 +1166,51 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // Log the request with body + response + duration. // Sanitize: strip sensitive fields from signing payloads to prevent // leaking signatures, transaction data, or typed-data content to audit log. - if (callbacks?.onApiLog) { + // + // The audit-log read endpoints don't get logged — otherwise each read + // would persist the full prior history into a new row, recursively + // ballooning response_body across repeated reads. + const skipAuditLog = path.startsWith('/api/v1/activity') || path === '/docs' || path === '/admin/info' || path === '/auth/pair' + if (callbacks?.onApiLog && !skipAuditLog) { const { appName, imageUrl } = resolveAppInfo() - const SENSITIVE_KEYS = new Set([ - 'signature', 'serialized', 'serializedTx', 'signedTx', 'signed', - 'typedData', 'message', 'rawTx', 'hex', 'data', 'signedPayload', - 'msgs', 'memo', 'tx', 'txBytes', 'signDoc', 'authInfoBytes', 'bodyBytes', + // Audit logs are stored locally (SQLite) on the user's own machine, + // so the signing *inputs* (message, typedData, calldata, etc.) must + // be preserved verbatim — they're the exact thing a user needs to + // replay when debugging "what did I just sign?". Redacting them + // would defeat the audit log's primary purpose. + // + // The signed *outputs* (signature blob, serialized tx) are already + // returned to the dApp in the response body and don't add debug + // value when duplicated in the log, so we still trim those to keep + // log rows compact. + // Note: 'signature' is intentionally NOT trimmed — at ~130 chars it's small, + // and the audit log is the only place to retrieve a prior signature for + // regression debugging (recover-and-compare) without re-issuing the sign. + const SENSITIVE_OUTPUT_KEYS = new Set([ + 'apiKey', 'serialized', 'serializedTx', 'signedTx', 'signed', 'signedPayload', ]) - const sanitize = (obj: any, depth = 0): any => { + const trimOutputs = (obj: any, depth = 0): any => { if (!obj || typeof obj !== 'object' || depth > 8) return obj - if (Array.isArray(obj)) return obj.map(v => sanitize(v, depth + 1)) + if (Array.isArray(obj)) { + if (obj.length > 50) return `[trimmed ${obj.length} items]` + return obj.map(v => trimOutputs(v, depth + 1)) + } const out: any = {} for (const [k, v] of Object.entries(obj)) { - if (SENSITIVE_KEYS.has(k)) { out[k] = '[REDACTED]'; continue } - out[k] = (v && typeof v === 'object') ? sanitize(v, depth + 1) : v + if (SENSITIVE_OUTPUT_KEYS.has(k)) { out[k] = '[trimmed]'; continue } + out[k] = (v && typeof v === 'object') ? trimOutputs(v, depth + 1) : v } return out } - const isSigning = SIGNING_ROUTES.has(path) callbacks.onApiLog({ method, route: path, timestamp: requestStart, durationMs: Date.now() - requestStart, status, appName, imageUrl: imageUrl || undefined, - requestBody: isSigning ? sanitize(reqBody) : reqBody, - responseBody: isSigning ? sanitize(data) : data, + // Request body kept as-is so the user can see what they signed. + requestBody: reqBody, + // Response body trims large or sensitive output blobs but leaves compact + // fields intact for local debugging. + responseBody: trimOutputs(data), ...resolvedActivity, }) } @@ -971,6 +1236,14 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // Allowlist of upstream path prefixes the proxy may serve const WC_ALLOWED_PREFIXES = ['/_next/', '/chain-logos/', '/icons/', '/favicon.ico'] + const rewriteWcProxyBody = (body: string): string => body + .replace(/keepkey:\/\/launch\/wc/g, 'keepkey-vault://launch/wc') + .replace(/keepkey:\/\/wc/g, 'keepkey-vault://wc') + .replace(/keepkey%3A%2F%2Flaunch%2Fwc/gi, 'keepkey-vault%3A%2F%2Flaunch%2Fwc') + .replace(/keepkey%3A%2F%2Fwc/gi, 'keepkey-vault%3A%2F%2Fwc') + .replace(/KeepKey Desktop/g, 'KeepKey Vault') + .replace(/Launch Desktop/g, 'Launch Vault') + // Primary: everything under /wc/ is always proxied const isWcPrimaryPath = path === '/wc' || path.startsWith('/wc/') @@ -1022,6 +1295,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 }) } + if (ct && /text\/html|javascript|application\/json|text\/plain/i.test(ct)) { + const body = rewriteWcProxyBody(await upstream.text()) + return new Response(body, { status: upstream.status, headers: respHeaders }) + } + return new Response(upstream.body, { status: upstream.status, headers: respHeaders }) } catch { if (callbacks?.onApiLog) { @@ -1070,7 +1348,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 status: 'healthy', syncing: engine.isSyncing, apiVersion: 2, - supportedChains: CHAINS.map(c => c.networkId), + supportedChains: CHAINS.filter(c => isChainSupported(c, ds.firmwareVersion)).map(c => c.networkId), device_connected: engine.wallet !== null, version: callbacks?.getVersion?.() || 'unknown', connected: engine.wallet !== null, @@ -1106,6 +1384,36 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 }) } + if (path === '/admin/usb/devices' && method === 'GET') { + auth.requireAuth(req) + try { + return json(listUsbDevicesForAdmin()) + } catch (err: any) { + return json({ error: 'Failed to list USB devices', message: err?.message || String(err) }, 500) + } + } + + if (path === '/admin/usb/state' && method === 'GET') { + auth.requireAuth(req) + try { + const devices = listUsbDevicesForAdmin() + const keepKeyOnBus = devices.some(device => device.isKeepKey) + const deviceState = engine.getDeviceState() + return json({ + connected: engine.wallet !== null, + state: deviceState.state, + deviceId: deviceState.deviceId || null, + label: deviceState.label || null, + firmwareVersion: deviceState.firmwareVersion || null, + activeTransport: deviceState.activeTransport || null, + keepKeyOnBus, + usbDeviceCount: devices.length, + }) + } catch (err: any) { + return json({ error: 'Failed to read USB state', message: err?.message || String(err) }, 500) + } + } + // ═══════════════════════════════════════════════════════════════ // SWAGGER UI (public — branded API docs) // ═══════════════════════════════════════════════════════════════ @@ -1153,6 +1461,42 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // ═══════════════════════════════════════════════════════════════ if (method === 'POST' && SIGNING_ROUTES.has(path) && callbacks?.onSigningRequest) { auth.requireAuth(req) + + // Pre-approval payload validation. Some clients (and our own + // discovery code) POST `{}` to signing endpoints to probe which + // methods the wallet supports. Without this short-circuit every + // probe pops an approval dialog with nothing to sign, which spams + // the user and hides the real request. Reject empties with 400 + // before the approval flow runs. + // + // We look for *any* payload field that indicates this is a real + // signing attempt. The permissive "has any of these keys" check + // is intentional — the per-chain handlers below will run the + // full schema validation, we just need to avoid gating on empty. + // + // Keys are taken from schemas.ts so the check mirrors the actual + // wire contract each handler parses. When a new sign route is + // added to SIGNING_ROUTES, add its required-any list here (or + // extend the prefix match for route families that share a schema). + let probeCheckBody: any + try { + probeCheckBody = await req.clone().json() + } catch { + probeCheckBody = null + } + const requiredAny = requiredSigningFields(path) + if (requiredAny && (!probeCheckBody || typeof probeCheckBody !== 'object')) { + console.warn(`[REST] ${path} probe rejected: body is not an object`) + return json({ error: 'Empty or invalid signing payload' }, 400) + } + if (requiredAny && !requiredAny.some((k) => probeCheckBody[k] !== undefined)) { + console.warn( + `[REST] ${path} probe rejected: missing all of`, requiredAny, + 'keys seen:', Object.keys(probeCheckBody || {}), + ) + return json({ error: `Missing signing payload — expected one of: ${requiredAny.join(', ')}` }, 400) + } + const { appName } = resolveAppInfo() const id = crypto.randomUUID() const signingInfo: SigningRequestInfo = { id, method: path, appName } @@ -1173,6 +1517,39 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (preview.typedData) { signingInfo.typedDataDecoded = decodeEIP712(preview.typedData) } + } else if (path === '/eth/sign') { + // EIP-191 personal_sign: body is { address, addressNList, message }. + // Message arrives as a hex string per JSON-RPC spec; in practice it + // nearly always encodes UTF-8 text (SIWE, dApp login challenges). + // Decode to plaintext so the user sees what they're actually + // signing — raw hex alone is useless for consent. + signingInfo.from = preview.address + const raw = typeof preview.message === 'string' ? preview.message : '' + let text: string | undefined + let isUtf8Text = false + if (raw) { + const hexMatch = /^0x([0-9a-fA-F]*)$/.exec(raw) + if (hexMatch) { + try { + const buf = Buffer.from(hexMatch[1], 'hex') + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(buf) + text = decoded + isUtf8Text = true + } catch { + // Not valid UTF-8 — leave `text` undefined; UI will show hex. + } + } else { + // Already a plaintext string (non-spec clients). + text = raw + isUtf8Text = true + } + } + signingInfo.ethMessageDecoded = { + address: preview.address, + messageRaw: raw, + messageText: text, + isUtf8Text, + } } else if (path === '/ton/sign-transaction') { // TON: field names differ from EVM (to_address, amount, raw_tx) signingInfo.to = preview.to_address @@ -1181,6 +1558,71 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // Tron: field names differ from EVM (to_address, amount, raw_tx) signingInfo.to = preview.to_address signingInfo.value = preview.amount + } else if (path === '/solana/sign-message') { + const raw = typeof preview.message === 'string' ? preview.message : '' + const messageEncoding = /^[0-9a-fA-F]+$/.test(raw) ? 'hex' : 'base64' + const addressNList = pickAddressNList(preview, DEFAULT_SOLANA_ADDRESS_N) + const claimedSigner = typeof preview.pubkey === 'string' + ? preview.pubkey + : typeof preview.address === 'string' + ? preview.address + : undefined + let actualSigner = formatAddressNPath(addressNList) + try { + const wallet = requireWallet(engine) + const derived = await wallet.solanaGetAddress({ addressNList, showDisplay: false }) + const derivedSigner = typeof derived === 'string' ? derived : derived?.address + if (derivedSigner) actualSigner = derivedSigner + if (claimedSigner && derivedSigner && claimedSigner !== derivedSigner) { + throw new HttpError(400, 'Solana signer mismatch: claimed signer does not match address_n/addressNList') + } + } catch (e: any) { + if (e instanceof HttpError) throw e + console.warn('[REST] Could not derive Solana signer for preview:', e?.message || e) + } + signingInfo.chain = 'solana' + signingInfo.from = actualSigner + signingInfo.data = raw + signingInfo.needsBlindSigning = true + signingInfo.requiresAdvancedMode = true + signingInfo.solanaMessageDecoded = buildSolanaMessageDecodedInfo(raw, { + // Match hdwallet's SolanaSignMessage string coercion exactly: + // hex strings sign hex bytes, everything else signs base64 bytes. + encoding: messageEncoding, + signer: actualSigner, + }) + } else if (path === '/solana/sign-transaction') { + // Solana clear-signing: parse v0/legacy message, resolve ALTs, + // decode each instruction via the pioneer-discovery program + // registry. Best-effort — a parse/ALT-RPC failure surfaces an + // explicit warning in the UI rather than silently falling + // back to an unflagged simple-transfer dialog. + if (typeof preview.raw_tx === 'string') { + try { + const endpoint = getSetting('solana_rpc_endpoint') || DEFAULT_SOLANA_RPC_ENDPOINT + signingInfo.solanaDecoded = await buildSolanaDecodedInfo( + preview.raw_tx, + createRpcAltFetcher(endpoint), + ) + } catch (e: any) { + const errName = e?.name || 'Error' + const errMsg = e?.message || String(e) + // Surface error with type prefix so the UI banner shows a + // useful diagnostic ("SolanaTxParseError: ..." vs "TypeError: + // fetch failed") instead of a bare string. + signingInfo.solanaDecodeError = `${errName}: ${errMsg}` + // Full stack + raw tx goes to the vault log so we can + // reproduce the failure locally — don't ship raw bytes to + // the UI, but *do* leave a breadcrumb in the console. + console.warn( + '[REST] Solana decode failed:', errName, errMsg, + '\n raw_tx (base64):', preview.raw_tx, + '\n stack:', e?.stack, + ) + } + } else { + signingInfo.solanaDecodeError = 'missing raw_tx payload' + } } else { signingInfo.from = preview.from || preview.signerAddress signingInfo.to = preview.to @@ -1205,7 +1647,10 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 console.log(`[REST] needsBlindSigning=${signingInfo.needsBlindSigning}, source=${decoded?.source}`) } } - } catch { /* body parse failed, non-fatal */ } + } catch (e: any) { + if (e instanceof HttpError) throw e + console.warn('[REST] Signing preview extraction failed:', e?.message || e) + } // Check device AdvancedMode policy before presenting to user. // ONLY use cached features — never call getFeatures() here because @@ -1251,7 +1696,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'utxo:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'utxo', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1272,7 +1717,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'cosmos:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'cosmos', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1291,7 +1736,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'osmo:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'osmo', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1310,7 +1755,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'eth:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'eth', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1329,7 +1774,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'tendermint:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'tendermint', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1348,7 +1793,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'thor:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'thor', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1367,7 +1812,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'maya:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'maya', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1386,7 +1831,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'xrp:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'xrp', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1403,9 +1848,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (path === '/addresses/solana' && method === 'POST') { auth.requireAuth(req) + const fwBlock = requireChainSupport('solana') + if (fwBlock) return fwBlock const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'sol:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'sol', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1422,9 +1869,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (path === '/addresses/tron' && method === 'POST') { auth.requireAuth(req) + const fwBlock = requireChainSupport('tron') + if (fwBlock) return fwBlock const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'trx:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'trx', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1441,9 +1890,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (path === '/addresses/ton' && method === 'POST') { auth.requireAuth(req) + const fwBlock = requireChainSupport('ton') + if (fwBlock) return fwBlock const wallet = requireWallet(engine) const body = await parseRequest(req, S.AddressRequest) - const cacheKey = 'ton:' + JSON.stringify(body) + const cacheKey = scopedKey(engine, 'ton', body) const cached = addressCache.get(cacheKey) if (cached) return json({ address: cached }) const sd = showDisplay(body.show_display) @@ -1761,40 +2212,66 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.SolanaSignRequest) - const addressNList = body.addressNList || body.address_n || [0x8000002C, 0x800001F5, 0x80000000, 0x80000000] + const addressNList = pickAddressNList(body, DEFAULT_SOLANA_ADDRESS_N) // Pioneer returns full serialized tx: [compact-u16:sigCount][sig0(64)]...[sigN(64)][message] - // Firmware expects just the message bytes. Extract message portion. - let deviceRawTx = body.raw_tx + // Firmware expects just the message bytes. See solana-tx.ts for the + // wire-format contract and malformed-input rejection rules. const fullTx = Buffer.from(body.raw_tx, 'base64') - let pos = 0, sigCount = 0 - if (fullTx[0] < 0x80) { sigCount = fullTx[0]; pos = 1 } - else if (fullTx.length >= 2 && fullTx[1] < 0x80) { - sigCount = (fullTx[0] & 0x7f) | (fullTx[1] << 7); pos = 2 - } else if (fullTx.length >= 3) { - sigCount = (fullTx[0] & 0x7f) | ((fullTx[1] & 0x7f) << 7) | (fullTx[2] << 14); pos = 3 - } - const messageStart = pos + sigCount * 64 - if (sigCount > 0 && messageStart < fullTx.length) { - deviceRawTx = Buffer.from(fullTx.subarray(messageStart)).toString('base64') + let parsed + try { + parsed = parseSolanaTx(fullTx) + } catch (err) { + if (err instanceof SolanaTxParseError) throw new HttpError(400, err.message) + throw err } - - const result = await emuWrap(() => wallet.solanaSignTx({ - addressNList, - rawTx: deviceRawTx, - }), { operation: 'solanaSignTx', chain: 'Solana' }) - // Assemble signed tx: replace dummy 64-byte signature in full tx with real signature - if (result?.signature && body.raw_tx) { - const rawBytes = Buffer.from(body.raw_tx, 'base64') - const sigBytes = result.signature instanceof Uint8Array + // KeepKey firmware message type 752 (SolanaSignTx) parses legacy + // messages only. For versioned (v0) messages we route the exact + // message bytes — including the 0x80 prefix — through type 754 + // (SolanaSignMessage), which signs raw bytes with Ed25519 over the + // user's Solana key. The resulting 64-byte signature is valid for + // the original v0 tx because Solana computes signatures over the + // message payload (not the wrapper). + // + // Trade-off: the device displays a generic "sign message" prompt + // rather than a parsed-tx summary. Users must review the resolved + // accounts/amounts in the Vault approval dialog. Full on-device + // parsing of v0 + ALT display is a firmware-side follow-up. + let sigBytes: Uint8Array + if (parsed.isVersioned) { + const messageBytes = solanaMessageSlice(fullTx, parsed) + const msgResult = await emuWrap(() => wallet.solanaSignMessage({ + addressNList, + message: Buffer.from(messageBytes).toString('base64'), + showDisplay: body.show_display !== false, + }), { operation: 'solanaSignMessage', chain: 'Solana' }) + const sig = msgResult?.signature + if (!sig) throw new HttpError(500, 'Solana v0 sign: device returned no signature') + sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') + } else { + const deviceRawTx = Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64') + const result = await emuWrap(() => wallet.solanaSignTx({ + addressNList, + rawTx: deviceRawTx, + }), { operation: 'solanaSignTx', chain: 'Solana' }) + if (!result?.signature) return json(result) + sigBytes = result.signature instanceof Uint8Array ? result.signature : Buffer.from(result.signature, 'base64') - if (rawBytes.length > 65 && sigBytes.length === 64) { - sigBytes.forEach((b: number, i: number) => { rawBytes[1 + i] = b }) - return json({ signature: Buffer.from(sigBytes).toString('base64'), serializedTx: rawBytes.toString('base64') }) - } } - return json(result) + + // Assemble signed tx: write the real signature into the first sig + // slot (starts at `parsed.sigStart`, 64 bytes long). Same layout + // for legacy and v0 because the wrapper format is identical. + if (sigBytes.length !== 64) { + throw new HttpError(500, `Solana sign: unexpected signature length ${sigBytes.length}`) + } + const rawBytes = Buffer.from(body.raw_tx, 'base64') + if (rawBytes.length < parsed.sigStart + 64) { + throw new HttpError(500, 'Solana sign: raw tx too short to hold signature') + } + for (let i = 0; i < 64; i++) rawBytes[parsed.sigStart + i] = sigBytes[i] + return json({ signature: Buffer.from(sigBytes).toString('base64'), serializedTx: rawBytes.toString('base64') }) } // ── SOLANA MESSAGE SIGNING (firmware type 754) ────────────────── @@ -1850,10 +2327,254 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 toAddress: body.to_address, amount: body.amount, }), { operation: 'tonSignTx', chain: 'TON' }) - if (!result) throw Object.assign(new Error('tonSignTx returned no result'), { statusCode: 500 }) + if (!result) throw new HttpError(500, 'tonSignTx returned no result') return json(result) } + // ── MESSAGE SIGNING (firmware 7.14.1+) ──────────────────────── + // TIP-191 personal_sign for TRON. + // hash = keccak256("\x19TRON Signed Message:\n" + len + msg) + if (path === '/tron/sign-message' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.TronSignMessageRequest) + const addressNList = body.addressNList || body.address_n || [0x8000002C, 0x800000C3, 0x80000000, 0, 0] + const message = decodeMessageBody(body.message, body.is_text, 'tronSignMessage') + const result = await emuWrap(() => wallet.tronSignMessage({ + addressNList, + message, + showDisplay: body.show_display, + }), { operation: 'tronSignMessage', chain: 'Tron' }) + if (!result) throw new HttpError(500, 'tronSignMessage returned no result') + return json({ address: result.address, signature: toHex(result.signature) }) + } + + // TIP-191 verify — recovers signer pubkey from sig + checks claimed address. + if (path === '/tron/verify-message' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.TronVerifyMessageRequest) + // signature is regex-validated to 65 bytes hex by zod; parseHex is belt-and-braces. + const sig = parseHex(body.signature, 'tronVerifyMessage.signature', 65) + const message = decodeMessageBody(body.message, body.is_text, 'tronVerifyMessage') + const ok = await emuWrap(() => wallet.tronVerifyMessage({ + address: body.address, + signature: sig, + message, + }), { operation: 'tronVerifyMessage', chain: 'Tron' }) + return json({ verified: !!ok }) + } + + // TIP-712 typed-data signing (hash mode). Host pre-computes the + // domainSeparator + message hashes per the TIP-712 spec; device + // assembles keccak256("\x19\x01" || ds_hash || msg_hash) and signs. + if (path === '/tron/sign-typed-hash' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.TronSignTypedHashRequest) + const addressNList = body.addressNList || body.address_n || [0x8000002C, 0x800000C3, 0x80000000, 0, 0] + // Both hashes are regex-validated to 32 bytes hex by zod; parseHex + // re-checks length defensively in case the schema constraint loosens. + const dsHash = parseHex(body.domain_separator_hash, 'tronSignTypedHash.domain_separator_hash', 32) + const msgHash = body.message_hash + ? parseHex(body.message_hash, 'tronSignTypedHash.message_hash', 32) + : undefined + const result = await emuWrap(() => wallet.tronSignTypedHash({ + addressNList, + domainSeparatorHash: dsHash, + messageHash: msgHash, + }), { operation: 'tronSignTypedHash', chain: 'Tron' }) + if (!result) throw new HttpError(500, 'tronSignTypedHash returned no result') + return json({ address: result.address, signature: toHex(result.signature) }) + } + + // Bare Ed25519 SignMessage for TON. Firmware fences this behind + // the AdvancedMode policy — without it, expect Failure. + if (path === '/ton/sign-message' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.TonSignMessageRequest) + const addressNList = body.addressNList || body.address_n || [0x8000002C, 0x8000025F, 0x80000000] + const message = decodeMessageBody(body.message, body.is_text, 'tonSignMessage') + const result = await emuWrap(() => wallet.tonSignMessage({ + addressNList, + message, + showDisplay: body.show_display, + }), { operation: 'tonSignMessage', chain: 'TON' }) + if (!result) throw new HttpError(500, 'tonSignMessage returned no result') + return json({ publicKey: toHex(result.publicKey), signature: toHex(result.signature) }) + } + + // Domain-separated Solana off-chain message. Firmware constructs + // "\xff" || "solana offchain" || version || format || length || msg + // and Ed25519-signs the envelope. + if (path === '/solana/sign-offchain-message' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.SolanaSignOffchainMessageRequest) + const addressNList = body.addressNList || body.address_n || [0x8000002C, 0x800001F5, 0x80000000, 0x80000000] + const message = decodeMessageBody(body.message, body.is_text, 'solanaSignOffchainMessage') + // Off-chain spec: 1212-byte ceiling for formats 0/1. Firmware + // rejects above this anyway; enforcing here surfaces the error + // pre-USB-roundtrip with a clearer source. + if (message.length > 1212) { + throw new HttpError(400, `solanaSignOffchainMessage.message: exceeds 1212-byte off-chain spec ceiling (got ${message.length})`) + } + const result = await emuWrap(() => wallet.solanaSignOffchainMessage({ + addressNList, + version: body.version, + messageFormat: body.message_format, + message, + showDisplay: body.show_display, + }), { operation: 'solanaSignOffchainMessage', chain: 'Solana' }) + if (!result) throw new HttpError(500, 'solanaSignOffchainMessage returned no result') + return json({ publicKey: toHex(result.publicKey), signature: toHex(result.signature) }) + } + + // ── TON BUILD + FINALIZE (2 endpoints) ──────────────────────── + // Exposes the local v4R2 BOC builder so thin clients (browser + // extension, mobile) don't have to embed a TON lib + toncenter + // plumbing just to construct a transfer. The existing desktop + // flow in txbuilder/index.ts already uses the same helpers — + // these endpoints are a thin REST shell around them. + if (path === '/ton/build-transfer' && method === 'POST') { + auth.requireAuth(req) + const body = await parseRequest(req, S.TonBuildTransferRequest) + + // Memo cap. Plain-text TON memos are encoded into a single cell + // alongside the 32-bit op code; ~120 bytes UTF-8 is a safe ceiling + // (1023-bit cell budget minus framing). Longer memos technically + // require a continuation cell ref, which buildInternalMessage + // doesn't emit — without this guard the user gets a cryptic + // BitWriter overflow deep in the assembler. + if (body.memo && Buffer.byteLength(body.memo, 'utf8') > 120) { + throw new HttpError(400, 'memo too large (max 120 bytes UTF-8)') + } + + // Seqno + wallet-state fetch fails independently; run in parallel + // so the caller eats one RTT rather than two, and surface both + // errors via a single diagnostic when the network's down. + let seqno: number + let walletState: { initialized: boolean; balance: string } + try { + ;[seqno, walletState] = await Promise.all([ + getTonSeqno(body.fromAddress), + getTonWalletState(body.fromAddress), + ]) + } catch (e: any) { + throw new HttpError(502, `TON network error — cannot determine wallet state: ${e.message}`) + } + + const needsDeploy = !walletState.initialized + if (needsDeploy && !body.publicKeyHex) { + // Firmware needs the pubkey to derive the v4R2 contract data + // cell for StateInit — without it, the first-ever tx from a + // fresh address can't be constructed. Make the failure loud + // rather than silently producing an un-broadcastable tx. + throw new HttpError(400, 'TON wallet not initialized — publicKeyHex required for first-time deployment') + } + + // 5-minute validity window. The hardware wallet confirmation UI + // can take 30s+ for a careful user, and the v4R2 wallet + // contract rejects messages past expireAt — anything tighter + // than ~2 min risks a "expired" failure after the user already + // confirmed on the device. If the device-side flow (PIN + + // passphrase + multi-step confirm) takes longer than 5 min, the + // caller must call /ton/build-transfer again to refresh expireAt + // before signing — we have no way to extend it post-hoc without + // changing the bodyHash the device just signed. + const expireAt = Math.floor(Date.now() / 1000) + 300 + + const build = buildTonTransfer({ + fromAddress: body.fromAddress, + to: body.toAddress, + amountNano: body.amountNano, + memo: body.memo, + seqno, + expireAt, + needsDeploy, + publicKeyHex: body.publicKeyHex, + }) + + return json({ + build, + // Convenience fields the client would otherwise have to pluck + // off `build` — flatten the ones most callers need. + bodyHash: build.bodyHash, + rawTx: build.rawTx, + seqno: build.seqno, + expireAt: build.expireAt, + needsDeploy: build.needsDeploy, + // Approximate fees for the UI. Clear-signing makes the exact + // figure visible on-device; this is just so the send screen + // can surface an estimate before the user commits. + feeEstimate: needsDeploy ? '0.01' : '0.005', + }) + } + + if (path === '/ton/finalize-transfer' && method === 'POST') { + auth.requireAuth(req) + const body = await parseRequest(req, S.TonFinalizeTransferRequest) + + // Signature is 64 bytes Ed25519. Hex validation in the schema + // catches length mismatches before they bubble into the + // assembler as a cryptic BitWriter error. + const sigBuf = Buffer.from(body.signature, 'hex') + if (sigBuf.length !== 64) { + throw new HttpError(400, 'signature must decode to 64 bytes') + } + + const buildResult = body.build as unknown as TonBuildResult + if (!buildResult?._internal) { + throw new HttpError(400, 'build._internal missing — pass the full object returned by /ton/build-transfer') + } + if (typeof buildResult.bodyHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(buildResult.bodyHash)) { + throw new HttpError(400, 'build.bodyHash missing or not 32-byte hex') + } + + // Detect a client that mutated _internal (amount, destination, + // memo, seqno, expireAt) after the device already signed the + // original bodyHash. Without this, broadcast=false would return + // a structurally-valid BOC that carries a signature over different + // bytes than what it now encodes — the caller can't tell the tx + // is doomed until TonCenter rejects it (or worse, with a collision, + // never). + let recomputedHash: string + try { + recomputedHash = computeTonBodyHash(buildResult) + } catch (e: any) { + throw new HttpError(400, `build object malformed — cannot reconstruct unsigned body: ${e.message}`) + } + if (recomputedHash !== buildResult.bodyHash.toLowerCase()) { + throw new HttpError(400, 'build tampered — _internal state does not match bodyHash') + } + + const { boc, extMsgHash } = assembleTonSignedBoc(buildResult, sigBuf) + + // broadcast=false lets a caller handle the broadcast elsewhere + // (offline signing, pre-flight BOC inspection, etc.). Default + // true because the common path is build → sign → broadcast in + // one user action. + const broadcast = body.broadcast !== false + if (!broadcast) { + return json({ boc, txid: extMsgHash, broadcasted: false }) + } + + try { + await broadcastTonBoc(boc) + } catch (e: any) { + // Surface the BOC and the txid even on broadcast failure so + // the caller can retry broadcast without re-signing. + throw new HttpError( + 502, + `TON broadcast failed (boc preserved in error payload): ${e.message}`, + { boc, txid: extMsgHash }, + ) + } + + return json({ boc, txid: extMsgHash, broadcasted: true }) + } + // ── DEVICE INFO (2 endpoints — read-only) ──────────────────── if (path === '/system/info/get-features' && method === 'POST') { auth.requireAuth(req) @@ -1866,7 +2587,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.GetPublicKeyRequest) - const cacheKey = JSON.stringify(body) + const cacheKey = scopedKey(engine, 'pubkey', body) const cached = pubkeyCache.get(cacheKey) if (cached) return json(cached) const sd = showDisplay(body.show_display) @@ -1980,6 +2701,291 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 }) } + // ── DEBUG PORTFOLIO ENDPOINTS ──────────────────────────────────── + // Verbose read-only views into cached balances, spam analysis, and + // token visibility overrides. Useful for diagnosing balance/spam issues + // without needing to dig through the SQLite DB directly. + + if (path === '/api/debug/portfolio' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Unavailable for passphrase wallet sessions' }, 403) + const ds = engine.getDeviceState() + const deviceId = ds.deviceId + if (!deviceId) return json({ error: 'No device connected' }, 503) + const cached = getCachedBalances(deviceId) + if (!cached) return json({ deviceId, cached: false, balances: [] }) + const visibilityMap = getAllTokenVisibility() + const now = Date.now() + const ageMs = now - cached.updatedAt + + let totalUsd = 0 + let totalTokens = 0 + let confirmedSpam = 0 + let possibleSpam = 0 + let hiddenByUser = 0 + const chains = cached.balances.map(b => { + totalUsd += b.balanceUsd + const tokenAnalysis = (b.tokens || []).map(t => { + totalTokens++ + const override = visibilityMap.get((t.caip || '').toLowerCase()) ?? null + const spam = detectSpamToken(t, override) + if (spam.isSpam && spam.level === 'confirmed') confirmedSpam++ + if (spam.isSpam && spam.level === 'possible') possibleSpam++ + if (override === 'hidden') hiddenByUser++ + return { ...t, _spam: spam, _userOverride: override } + }) + return { + chainId: b.chainId, + symbol: b.symbol, + address: b.address, + balance: b.balance, + balanceUsd: b.balanceUsd, + nativeBalanceUsd: b.nativeBalanceUsd, + tokenCount: tokenAnalysis.length, + tokens: tokenAnalysis, + } + }) + + return json({ + deviceId, + cached: true, + updatedAt: cached.updatedAt, + ageMs, + ageSec: Math.round(ageMs / 1000), + summary: { totalUsd, totalChains: chains.length, totalTokens, confirmedSpam, possibleSpam, hiddenByUser }, + chains, + }) + } + + if (path === '/api/debug/portfolio/chains' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Unavailable for passphrase wallet sessions' }, 403) + const ds = engine.getDeviceState() + const deviceId = ds.deviceId + if (!deviceId) return json({ error: 'No device connected' }, 503) + const cached = getCachedBalances(deviceId) + if (!cached) return json({ deviceId, cached: false, chains: [] }) + const now = Date.now() + return json({ + deviceId, + updatedAt: cached.updatedAt, + ageMs: now - cached.updatedAt, + chains: cached.balances.map(b => ({ + chainId: b.chainId, + symbol: b.symbol, + address: b.address, + balance: b.balance, + balanceUsd: b.balanceUsd, + nativeBalanceUsd: b.nativeBalanceUsd ?? null, + tokenCount: b.tokens?.length ?? 0, + })), + }) + } + + if (path === '/api/debug/portfolio/tokens' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Unavailable for passphrase wallet sessions' }, 403) + const ds = engine.getDeviceState() + const deviceId = ds.deviceId + if (!deviceId) return json({ error: 'No device connected' }, 503) + const cached = getCachedBalances(deviceId) + if (!cached) return json({ deviceId, cached: false, tokens: [] }) + const visibilityMap = getAllTokenVisibility() + const tokens: any[] = [] + for (const b of cached.balances) { + for (const t of b.tokens || []) { + const override = visibilityMap.get((t.caip || '').toLowerCase()) ?? null + const spam = detectSpamToken(t, override) + tokens.push({ chain: b.chainId, ...t, _spam: spam, _userOverride: override }) + } + } + tokens.sort((a, b) => (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0)) + return json({ deviceId, total: tokens.length, tokens }) + } + + if (path === '/api/debug/portfolio/spam' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Unavailable for passphrase wallet sessions' }, 403) + const ds = engine.getDeviceState() + const deviceId = ds.deviceId + if (!deviceId) return json({ error: 'No device connected' }, 503) + const cached = getCachedBalances(deviceId) + if (!cached) return json({ deviceId, cached: false, spam: [] }) + const visibilityMap = getAllTokenVisibility() + const showPossible = new URL(req.url).searchParams.get('level') !== 'confirmed' + const spam: any[] = [] + for (const b of cached.balances) { + for (const t of b.tokens || []) { + const override = visibilityMap.get((t.caip || '').toLowerCase()) ?? null + const result = detectSpamToken(t, override) + if (!result.isSpam) continue + if (!showPossible && result.level !== 'confirmed') continue + spam.push({ chain: b.chainId, ...t, _spam: result, _userOverride: override }) + } + } + spam.sort((a, b) => { + if (a._spam.level === b._spam.level) return (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0) + return a._spam.level === 'confirmed' ? -1 : 1 + }) + return json({ deviceId, total: spam.length, spam }) + } + + // ── Pioneer diagnostic: drive a full chunked portfolio call and report results ── + if (path === '/api/debug/pioneer-audit' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Unavailable for passphrase wallet sessions' }, 403) + const ds = engine.getDeviceState() + const deviceId = ds.deviceId + if (!deviceId) return json({ error: 'No device connected' }, 503) + + // Build pubkey list from cached DB entries (avoids device round-trips) + const cachedPks = getCachedPubkeys(deviceId) + const pubkeys: Array<{ caip: string; pubkey: string; label: string }> = [] + + // getCachedPubkeys() returns chainId but not caip — derive from the shared CHAINS registry + // so new chains added to chains.ts are automatically covered. + const chainIdToCaip = new Map(CHAINS.map(c => [c.id, c.caip])) + + // UTXO (xpubs) and non-EVM address-based entries + for (const pk of cachedPks) { + const caip = chainIdToCaip.get(pk.chainId) || '' + if (pk.xpub) pubkeys.push({ caip, pubkey: pk.xpub, label: `${pk.chainId}:xpub` }) + else if (pk.address) pubkeys.push({ caip, pubkey: pk.address, label: `${pk.chainId}:addr` }) + } + + // EVM chains — use ETH address from cache for each supported EVM chain + const ethCachedPk = cachedPks.find(p => p.chainId === 'ethereum' && p.address) + if (ethCachedPk?.address) { + const evmCaips: Array<[string, string]> = [ + ['eip155:1/slip44:60', 'ethereum'], + ['eip155:137/slip44:966', 'polygon'], + ['eip155:42161/slip44:60', 'arbitrum'], + ['eip155:10/slip44:60', 'optimism'], + ['eip155:43114/slip44:60', 'avalanche'], + ['eip155:56/slip44:60', 'bsc'], + ['eip155:8453/slip44:60', 'base'], + ['eip155:100/slip44:60', 'gnosis'], + ] + for (const [caip, label] of evmCaips) { + if (!pubkeys.find(p => p.caip === caip)) { + pubkeys.push({ caip, pubkey: ethCachedPk.address, label: `${label}:evm` }) + } + } + } + + // Cached non-EVM addresses (cosmos, xrp, etc.) + const cachedBalances = getCachedBalances(deviceId) + if (cachedBalances) { + const cosmosChains: Record = { + cosmos: 'cosmos:cosmoshub-4/slip44:118', + thorchain: 'cosmos:thorchain-mainnet-v1/slip44:931', + mayachain: 'cosmos:mayachain-mainnet-v1/slip44:931', + osmosis: 'cosmos:osmosis-1/slip44:118', + } + for (const b of cachedBalances.balances) { + const caip = cosmosChains[b.chainId] + if (caip && b.address && !b.address.startsWith('xpub') && !b.address.startsWith('zpub') && !b.address.startsWith('ypub')) { + if (!pubkeys.find(p => p.caip === caip)) { + pubkeys.push({ caip, pubkey: b.address, label: `${b.chainId}:addr` }) + } + } + if (b.chainId === 'ripple' && b.address) { + const xrpCaip = 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144' + if (!pubkeys.find(p => p.caip === xrpCaip)) { + pubkeys.push({ caip: xrpCaip, pubkey: b.address, label: 'ripple:addr' }) + } + } + } + } + + const CHUNK_SIZE = 8 + const chunks: typeof pubkeys[] = [] + for (let i = 0; i < pubkeys.length; i += CHUNK_SIZE) chunks.push(pubkeys.slice(i, i + CHUNK_SIZE)) + + // Call Pioneer for each chunk + let pioneer: any + try { pioneer = await callbacks.getPioneer() } catch (e: any) { + return json({ error: `Pioneer init failed: ${e.message}`, pubkeyCount: pubkeys.length }) + } + + const chunkResults: any[] = [] + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + const t0 = Date.now() + try { + const resp = await Promise.race([ + pioneer.GetPortfolioBalances({ pubkeys: chunk.map(p => ({ caip: p.caip, pubkey: p.pubkey })) }, { forceRefresh: true }), + new Promise((_, rej) => setTimeout(() => rej(new Error('60s timeout')), 60000)), + ]) + const entries = Array.isArray(resp?.data) ? resp.data : (Array.isArray(resp?.data?.balances) ? resp.data.balances : (Array.isArray(resp) ? resp : [])) + chunkResults.push({ + chunk: i + 1, + pubkeys: chunk.map(p => ({ caip: p.caip, label: p.label, pubkey: p.pubkey.substring(0, 20) + '...' })), + ok: true, + durationMs: Date.now() - t0, + entryCount: entries.length, + entries: entries.map((e: any) => ({ + caip: e.caip, symbol: e.symbol, balance: e.balance, valueUsd: e.valueUsd, + dataSource: e.dataSource, isStale: e.isStale, + })), + }) + } catch (e: any) { + chunkResults.push({ + chunk: i + 1, + pubkeys: chunk.map(p => ({ caip: p.caip, label: p.label })), + ok: false, + durationMs: Date.now() - t0, + error: e?.message || String(e), + }) + } + } + + const succeeded = chunkResults.filter(r => r.ok).length + const failed = chunkResults.filter(r => !r.ok).length + return json({ + deviceId, + pubkeyCount: pubkeys.length, + chunkCount: chunks.length, + chunkSize: CHUNK_SIZE, + succeeded, + failed, + pioneerUrl: callbacks.getPioneerApiBase?.() || 'unknown', + chunks: chunkResults, + }) + } + + if (path === '/api/debug/token-visibility' && method === 'GET') { + auth.requireAuth(req) + const hidden = getTokensByVisibility('hidden') + const visible = getTokensByVisibility('visible') + return json({ + total: hidden.length + visible.length, + hidden: hidden.map(r => ({ caip: r.caip, updatedAt: r.updatedAt })), + visible: visible.map(r => ({ caip: r.caip, updatedAt: r.updatedAt })), + }) + } + + if (path.startsWith('/api/debug/token-visibility/') && method === 'PUT') { + auth.requireAuth(req) + const caip = decodeURIComponent(path.slice('/api/debug/token-visibility/'.length)) + if (!caip) return json({ error: 'caip required in path' }, 400) + const body = await req.json().catch(() => ({})) as any + const status = body?.status + if (status !== 'visible' && status !== 'hidden') { + return json({ error: 'body.status must be "visible" or "hidden"' }, 400) + } + setTokenVisibility(caip, status) + return json({ caip, status, updated: true }) + } + + if (path.startsWith('/api/debug/token-visibility/') && method === 'DELETE') { + auth.requireAuth(req) + const caip = decodeURIComponent(path.slice('/api/debug/token-visibility/'.length)) + if (!caip) return json({ error: 'caip required in path' }, 400) + removeTokenVisibility(caip) + return json({ caip, removed: true }) + } + if (path === '/api/pubkeys/batch' && method === 'POST') { auth.requireAuth(req) const wallet = requireWallet(engine) @@ -1994,7 +3000,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // SDK sends type='address' for chains that need actual addresses, not xpubs. if (p.type === 'address') { const primaryNetwork = (p.networks || [])[0] || '' - const addrCacheKey = `batch-addr:${JSON.stringify(p.address_n)}:${primaryNetwork}` + const addrCacheKey = scopedKey(engine, 'batch-addr', { n: p.address_n, net: primaryNetwork }) const cachedAddr = addressCache.get(addrCacheKey) if (cachedAddr) { results.push({ @@ -2036,18 +3042,22 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const r = await wallet.cosmosGetAddress({ addressNList: addrNList, showDisplay: false }) address = typeof r === 'string' ? r : r?.address || '' } else if (coinType === 501) { - // Solana uses ed25519 with 4-element path (m/44'/501'/0'/0') — don't extend to 5 - const solNList = p.address_n - const r = await wallet.solanaGetAddress({ addressNList: solNList, showDisplay: false }) - address = typeof r === 'string' ? r : (r as any)?.address || '' + if (!requireChainSupport('solana')) { + const solNList = p.address_n + const r = await wallet.solanaGetAddress({ addressNList: solNList, showDisplay: false }) + address = typeof r === 'string' ? r : (r as any)?.address || '' + } } else if (coinType === 195) { - const r = await wallet.tronGetAddress({ addressNList: addrNList, showDisplay: false }) - address = typeof r === 'string' ? r : (r as any)?.address || '' + if (!requireChainSupport('tron')) { + const r = await wallet.tronGetAddress({ addressNList: addrNList, showDisplay: false }) + address = typeof r === 'string' ? r : (r as any)?.address || '' + } } else if (coinType === 607) { - // TON uses ed25519 with 3-element path (m/44'/607'/0') — don't extend to 5 - const tonNList = p.address_n - const r = await wallet.tonGetAddress({ addressNList: tonNList, showDisplay: false, bounceable: false }) - address = typeof r === 'string' ? r : (r as any)?.address || '' + if (!requireChainSupport('ton')) { + const tonNList = p.address_n + const r = await wallet.tonGetAddress({ addressNList: tonNList, showDisplay: false, bounceable: false }) + address = typeof r === 'string' ? r : (r as any)?.address || '' + } } if (address) { @@ -2074,7 +3084,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } // ── xpub/ypub/zpub-type paths (UTXO chains) ── - const cacheKey = JSON.stringify({ address_n: p.address_n, script_type: p.script_type }) + const cacheKey = scopedKey(engine, 'batch-pubkey', { address_n: p.address_n, script_type: p.script_type }) const cached = pubkeyCache.get(cacheKey) if (cached) { results.push({ @@ -2128,6 +3138,249 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 }) } + // ═══════════════════════════════════════════════════════════════ + // SIGNING HISTORY / AUDIT LOG (auth-required — exposes payloads) + // PRIVACY: standard-wallet history is hidden during passphrase sessions, + // matching the RPC `getApiLogs` / `getRecentActivity` behavior. + // ═══════════════════════════════════════════════════════════════ + if (path === '/api/v1/activity/recent' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ entries: [], count: 0 }) + const scope = getWalletDbScope() + if (!scope) return json({ entries: [], count: 0 }) + const url = new URL(req.url) + const q = url.searchParams + const limitRaw = q.get('limit') + const limit = limitRaw === null ? 50 : Number(limitRaw) + if (!Number.isFinite(limit)) { + throw new HttpError(400, 'Invalid limit: must be a number') + } + const entries = getRecentActivityFromLog( + Math.min(Math.max(limit, 1), 500), + q.get('chainId') || q.get('chain') || undefined, + scope.deviceId, + scope.walletId, + ) + return json({ entries, count: entries.length }) + } + + if (path === '/api/v1/activity' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ entries: [], count: 0 }) + const scope = getWalletDbScope() + if (!scope) return json({ entries: [], count: 0 }) + const url = new URL(req.url) + const q = url.searchParams + const parseNumParam = (name: string): number | undefined => { + const raw = q.get(name) + if (raw === null) return undefined + const n = Number(raw) + if (!Number.isFinite(n)) { + throw new HttpError(400, `Invalid ${name}: must be a number`) + } + return n + } + const entries = findApiLogs({ + ...scope, + route: q.get('route') || undefined, + activityType: q.get('activityType') || undefined, + txid: q.get('txid') || undefined, + chain: q.get('chain') || undefined, + since: parseNumParam('since'), + until: parseNumParam('until'), + limit: parseNumParam('limit'), + offset: parseNumParam('offset'), + }) + return json({ entries, count: entries.length }) + } + + if (path === '/api/v1/activity/rebuild' && method === 'POST') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) { + return json({ error: 'Activity rebuild is not available for passphrase-protected wallets' }, 403) + } + const scope = getWalletDbScope() + if (!scope) { + return json({ error: 'Wallet scope is not ready. Unlock the device and wait for seed identity.' }, 409) + } + const wallet = requireWallet(engine) + reqBody = await req.json().catch(() => ({})) + const body = (reqBody && typeof reqBody === 'object') ? reqBody as ActivityHistoryRebuildOptions : {} + const chainIds = [ + ...(Array.isArray(body.chainIds) ? body.chainIds : []), + ...(typeof body.chainId === 'string' ? [body.chainId] : []), + ] + const unknown = chainIds.filter(id => !CHAINS.some(c => c.id === id || c.symbol === id)) + if (unknown.length > 0) { + return json({ error: `Unknown chain id(s): ${unknown.join(', ')}` }, 400) + } + const result = await rebuildActivityHistory({ + wallet, + scope, + chains: CHAINS, + firmwareVersion: engine.getDeviceState().firmwareVersion, + options: body, + }) + return json(result) + } + + if (path.startsWith('/api/v1/activity/') && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Not found' }, 404) + const scope = getWalletDbScope() + if (!scope) return json({ error: 'Not found' }, 404) + const tail = path.split('/').pop() || '' + const id = Number(tail) + if (!Number.isFinite(id) || !Number.isInteger(id)) { + return json({ error: 'Invalid id' }, 400) + } + const entry = getApiLogById(id, scope.deviceId, scope.walletId) + if (!entry) return json({ error: 'Not found' }, 404) + return json(entry) + } + + // ═══════════════════════════════════════════════════════════════ + // SWAP HISTORY (auth-required — reports / external tooling) + // PRIVACY: standard-wallet history is hidden during passphrase + // sessions, matching the RPC `getSwapHistory` behavior. + // Read-only — the table is owned by swap-tracker / executeSwap. + // ═══════════════════════════════════════════════════════════════ + if (path === '/api/v1/swaps/stats' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) { + return json({ totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 }) + } + const scope = getWalletDbScope() + if (!scope) return json({ totalSwaps: 0, completed: 0, failed: 0, refunded: 0, pending: 0 }) + return json(getSwapHistoryStats(scope.deviceId, scope.walletId)) + } + + if (path === '/api/v1/swaps' && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ entries: [], count: 0 }) + const scope = getWalletDbScope() + if (!scope) return json({ entries: [], count: 0 }) + const url = new URL(req.url) + const q = url.searchParams + const parseNumParam = (name: string): number | undefined => { + const raw = q.get(name) + if (raw === null) return undefined + const n = Number(raw) + if (!Number.isFinite(n)) throw new HttpError(400, `Invalid ${name}: must be a number`) + return n + } + // Whitelist `status` so callers can't smuggle arbitrary text into the + // query — invalid values get rejected loudly instead of silently + // returning everything via a no-match LIKE. + const VALID_STATUSES: ReadonlyArray = [ + 'all', 'pending', 'confirming', 'output_detected', 'output_confirming', + 'output_confirmed', 'completed', 'failed', 'refunded', + ] + const rawStatus = q.get('status') + let status: SwapTrackingStatus | 'all' | undefined + if (rawStatus !== null) { + if (!VALID_STATUSES.includes(rawStatus as any)) { + throw new HttpError(400, `Invalid status: ${rawStatus} (allowed: ${VALID_STATUSES.join(', ')})`) + } + status = rawStatus as SwapTrackingStatus | 'all' + } + const entries = getSwapHistory({ + ...scope, + status, + asset: q.get('asset') || undefined, + fromDate: parseNumParam('fromDate'), + toDate: parseNumParam('toDate'), + limit: parseNumParam('limit'), + offset: parseNumParam('offset'), + }) + return json({ entries, count: entries.length }) + } + + if (path.startsWith('/api/v1/swaps/') && method === 'GET') { + auth.requireAuth(req) + if (engine.isPassphraseWallet) return json({ error: 'Not found' }, 404) + const scope = getWalletDbScope() + if (!scope) return json({ error: 'Not found' }, 404) + const tail = path.split('/').pop() || '' + if (!tail) return json({ error: 'Invalid txid' }, 400) + const record = getSwapHistoryByTxid(tail, scope.deviceId, scope.walletId) + if (!record) return json({ error: 'Not found' }, 404) + return json(record) + } + + // ═══════════════════════════════════════════════════════════════ + // SWAP AVAILABILITY (debug — picker classification visibility) + // Returns the same data the AssetPickerDialog uses to decide + // whether each row is selectable. Keyed by CAIP-19. Auth-gated. + // GET /api/v1/swap/availability/:caip — single asset + // GET /api/v1/swap/discovery?q=&limit=&status= — search + filter + // ═══════════════════════════════════════════════════════════════ + if (path.startsWith('/api/v1/swap/availability/') && method === 'GET') { + auth.requireAuth(req) + // Path is "/api/v1/swap/availability/" — caip itself contains + // ':' and '/' so we can't naively split. Slice from the prefix. + const caip = decodeURIComponent(path.slice('/api/v1/swap/availability/'.length)) + if (!caip) return json({ error: 'Missing caip' }, 400) + const { assessAvailability } = await import('../shared/swap-support-matrix') + const { networkDisplayName, chainMetaForCaip2 } = await import('../shared/swap-discovery') + const slash = caip.indexOf('/') + const chainCaip2 = slash >= 0 ? caip.slice(0, slash) : caip + return json({ + caip, + chainCaip2, + chainDisplayName: networkDisplayName(chainCaip2), + chainKnownToVault: !!chainMetaForCaip2(chainCaip2), + assessment: assessAvailability(caip), + }) + } + + if (path === '/api/v1/swap/discovery' && method === 'GET') { + auth.requireAuth(req) + const url = new URL(req.url) + const q = url.searchParams.get('q') || '' + const statusFilter = url.searchParams.get('status') + const limitRaw = url.searchParams.get('limit') + const limit = limitRaw ? Math.max(1, Math.min(500, Number(limitRaw))) : 50 + if (limitRaw && !Number.isFinite(Number(limitRaw))) { + throw new HttpError(400, `Invalid limit: ${limitRaw}`) + } + + const { buildAssetEntries, buildSearchIndex, searchEntries, bucketFor } = await import('../shared/swap-discovery') + // Debug endpoint — uses the same swappable list as the picker but + // intentionally drops balances. The classification (status, providers, + // bucket) is what callers want to inspect; balances would skew rows + // into bucket 0/1 and conflate UX-state with matrix correctness. + const { getSwapAssets } = await import('./swap') + const swappable = await getSwapAssets() + const entries = await buildAssetEntries({ swappable, balances: [] }) + const idx = buildSearchIndex(entries) + let results = searchEntries(idx, q) + if (statusFilter) { + const allowed = ['swappable', 'unknown', 'unsupported_chain', 'unsupported_token'] + if (!allowed.includes(statusFilter)) { + throw new HttpError(400, `Invalid status: ${statusFilter} (allowed: ${allowed.join(', ')})`) + } + results = results.filter(e => e.availability.status === statusFilter) + } + return json({ + query: q, + statusFilter: statusFilter || null, + totalUniverse: entries.length, + matched: results.length, + entries: results.slice(0, limit).map(e => ({ + caip: e.caip, + chainId: e.chainId, + symbol: e.symbol, + name: e.name, + isNative: e.isNative, + hasBalance: !!e.balance, + pioneerSwappable: !!e.swappable, + bucket: bucketFor(e), + availability: e.availability, + })), + }) + } + // ═══════════════════════════════════════════════════════════════ // SYSTEM MANAGEMENT (keepkey-desktop compatible — require auth) // ═══════════════════════════════════════════════════════════════ @@ -2176,6 +3429,12 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const body = await parseRequest(req, S.ApplyPoliciesRequest) await wallet.applyPolicy(body) featuresCache = null + try { + await engine.refreshFeaturesSnapshot() + } catch (e: any) { + engine.invalidateFeaturesSnapshot() + console.warn('[REST] Applied policy but failed to refresh features:', e?.message || e) + } return json({ success: true }) } @@ -2250,16 +3509,17 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } const zcashShieldedDef = CHAINS.find(c => c.id === 'zcash-shielded') - const zcashFwSupported = zcashShieldedDef && isChainSupported(zcashShieldedDef, engine.state?.firmwareVersion) + const zcashFwSupported = zcashShieldedDef && isChainSupported(zcashShieldedDef, engine.getDeviceState().firmwareVersion) + const zcashFwError = `Zcash requires firmware >= ${zcashShieldedDef?.minFirmware ?? 'unknown'}` if (path === '/api/zcash/shielded/status' && method === 'GET') { - if (!zcashFwSupported) return json({ ready: false, error: 'Zcash requires firmware >= 7.11.0' }) + if (!zcashFwSupported) return json({ ready: false, error: zcashFwError }) return json({ ready: isSidecarReady() }) } // All mutating zcash endpoints require firmware support if (path.startsWith('/api/zcash/shielded/') && path !== '/api/zcash/shielded/status' && !zcashFwSupported) { - return json({ error: 'Zcash requires firmware >= 7.11.0' }, 503) + return json({ error: zcashFwError }, 503) } if (path === '/api/zcash/shielded/init' && method === 'POST') { @@ -2274,10 +3534,22 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json({ error: 'seed_hex init disabled — use from_device: true' }, 403) } + if (path === '/api/zcash/shielded/display-address' && method === 'POST') { + auth.requireAuth(req) + const body = await parseRequest(req, S.ZcashDisplayAddressRequest) + const wallet = requireWallet(engine) + return json(await displayOrchardAddressOnDevice(wallet, body.account ?? 0)) + } + if (path === '/api/zcash/shielded/scan' && method === 'POST') { auth.requireAuth(req) const body = await parseRequest(req, S.ZcashScanRequest) - const result = await scanOrchardNotes(body.start_height) + const wallet = requireWallet(engine) + // REST callers haven't necessarily gone through the Privacy tab init, + // so the sidecar may have no FVK yet — refresh from device first + // rather than failing with "No FVK set". + await ensureFvkLoaded(wallet, 0) + const result = await scanOrchardNotes(body.start_height, body.full_rescan) return json(result) } @@ -2308,8 +3580,17 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json(result) } - // ── REST v2 data routes (balances, market, UTXOs, swap, etc.) ── - if (path.startsWith('/api/v2/') && !path.startsWith('/api/v2/devices') && !path.startsWith('/api/v2/sweep/')) { + // ── REST v2 swap routes (UI control + parsed/raw quotes + history) ── + // Must come before handleV2DataRoute so the new /swap/* paths match + // the dedicated handler instead of falling through to the legacy + // pioneer-passthrough quote endpoint. + if (path.startsWith('/api/v2/swap')) { + const resp = await handleSwapRoute(path, method, req, auth, json, callbacks) + if (resp) return resp + } + + // ── REST v2 data routes (balances, market, UTXOs, etc.) ── + if (path.startsWith('/api/v2/') && !path.startsWith('/api/v2/devices') && !path.startsWith('/api/v2/sweep/') && !path.startsWith('/api/v2/swap')) { const resp = await handleV2DataRoute(path, method, req, auth, json) if (resp) return resp } @@ -2326,13 +3607,19 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json({ error: 'Not found', path }, 404) } catch (err: any) { - if (err.status) { - return json({ error: err.message }, err.status) + // HttpError carries a typed status + optional `details` (e.g. + // { boc, txid } on TON broadcast failure so a client can retry + // broadcast without re-signing). Anything without a status falls + // through to the firmware-failure extraction below as a 500. + if (typeof err?.status === 'number') { + const payload: Record = { error: err.message } + if (err.details && typeof err.details === 'object') payload.details = err.details + return json(payload, err.status) } // Extract firmware Failure message if present (hdwallet wraps them) const fwMsg = err?.message?.message || err?.message || 'Internal error' const fwCode = err?.message?.code ?? err?.code - console.error('[REST] Error:', typeof fwMsg === 'string' ? fwMsg : JSON.stringify(fwMsg), + console.error(`[REST] Error on ${method} ${path}:`, typeof fwMsg === 'string' ? fwMsg : JSON.stringify(fwMsg), fwCode != null ? `(code ${fwCode})` : '') return json({ error: typeof fwMsg === 'string' ? fwMsg : 'Internal error', code: fwCode }, 500) } finally { diff --git a/projects/keepkey-vault/src/bun/rest-pioneer.ts b/projects/keepkey-vault/src/bun/rest-pioneer.ts index 2b122d33..bd8f079a 100644 --- a/projects/keepkey-vault/src/bun/rest-pioneer.ts +++ b/projects/keepkey-vault/src/bun/rest-pioneer.ts @@ -148,29 +148,7 @@ export async function handleV2DataRoute( return json({ data: resp?.data || resp }) } - // ── Swap ─────────────────────────────────────────────────────── - - if (path === '/api/v2/swap/quote' && method === 'POST') { - auth.requireAuth(req) - const body = await parseRequest(req, S.SwapQuoteRequest) - const pioneer = await getPioneer() - const resp = await pioneer.Quote({ - sellAsset: body.sellAsset, - buyAsset: body.buyAsset, - sellAmount: body.sellAmount, - senderAddress: body.senderAddress, - recipientAddress: body.recipientAddress, - slippage: body.slippage ?? 3, - }) - return json({ data: resp?.data || resp }) - } - - if (path === '/api/v2/swap/inbound-addresses' && method === 'GET') { - auth.requireAuth(req) - const pioneer = await getPioneer() - const resp = await pioneer.GetInboundAddresses() - return json({ data: resp?.data || resp }) - } + // ── Swap routes moved to rest-swap.ts (/api/v2/swap/*) ──────────── return null diff --git a/projects/keepkey-vault/src/bun/rest-swap.ts b/projects/keepkey-vault/src/bun/rest-swap.ts new file mode 100644 index 00000000..3eb44500 --- /dev/null +++ b/projects/keepkey-vault/src/bun/rest-swap.ts @@ -0,0 +1,133 @@ +/** + * REST swap routes — `/api/v2/swap/*`. + * + * Strict scope: drive the in-app SwapDialog as if a user were clicking it. + * Nothing else lives here. No headless quoting, no asset list, no history, + * no debug passthroughs. Quoting + signing + broadcast all flow through the + * dialog and the device, where the user can review and approve. + * + * - GET /state → snapshot of the dialog's visible state + * - POST /open → pop the dialog with optional seed fields + * - POST /set → change a field while the dialog is open + * - POST /requote → force a re-quote with current inputs + * - POST /close → dismiss the dialog + * + * All endpoints require bearer-token auth. + */ +import type { AuthStore } from './auth' +import type { RestApiCallbacks } from './rest-api' +import { parseRequest } from './validate' +import * as S from './schemas' +import type { SwapUiCommand } from '../shared/types' + +const TAG = '[rest-swap]' + +type JsonFn = (data: unknown, status?: number) => Response + +/** + * Handle /api/v2/swap/ routes. Returns a Response if matched, null otherwise. + */ +export async function handleSwapRoute( + path: string, + method: string, + req: Request, + auth: AuthStore, + json: JsonFn, + callbacks: RestApiCallbacks | undefined, +): Promise { + if (!path.startsWith('/api/v2/swap/') && path !== '/api/v2/swap') return null + + try { + if (path === '/api/v2/swap/state' && method === 'GET') { + auth.requireAuth(req) + if (!callbacks?.getSwapUiState) return json({ error: 'Swap UI mirror not wired' }, 503) + const snap = callbacks.getSwapUiState() + return json({ data: snap.state, updatedAt: snap.updatedAt }) + } + + if (path === '/api/v2/swap/assets' && method === 'GET') { + auth.requireAuth(req) + const { getSwapAssets } = await import('./swap') + const assets = await getSwapAssets() + return json({ data: assets }) + } + + if (path === '/api/v2/swap/open' && method === 'POST') { + auth.requireAuth(req) + const body = await parseRequest(req, S.SwapUiOpenRequest) + if (!callbacks?.sendSwapCmd) return json({ error: 'Swap UI bridge not wired' }, 503) + const validation = await validateSeedAssets(body) + if (validation) return json(validation, 400) + callbacks.sendSwapCmd({ kind: 'open', ...body } as SwapUiCommand) + return json({ data: { ok: true } }) + } + + if (path === '/api/v2/swap/set' && method === 'POST') { + auth.requireAuth(req) + const body = await parseRequest(req, S.SwapUiSetRequest) + if (!callbacks?.sendSwapCmd) return json({ error: 'Swap UI bridge not wired' }, 503) + const validation = await validateSeedAssets(body) + if (validation) return json(validation, 400) + callbacks.sendSwapCmd({ kind: 'set', ...body } as SwapUiCommand) + return json({ data: { ok: true } }) + } + + if (path === '/api/v2/swap/requote' && method === 'POST') { + auth.requireAuth(req) + if (!callbacks?.sendSwapCmd) return json({ error: 'Swap UI bridge not wired' }, 503) + callbacks.sendSwapCmd({ kind: 'requote' }) + return json({ data: { ok: true } }) + } + + if (path === '/api/v2/swap/advance' && method === 'POST') { + auth.requireAuth(req) + if (!callbacks?.sendSwapCmd) return json({ error: 'Swap UI bridge not wired' }, 503) + // UI-navigation only: input → review. No signing triggered by this call. + callbacks.sendSwapCmd({ kind: 'advance' } as SwapUiCommand) + return json({ data: { ok: true } }) + } + + if (path === '/api/v2/swap/confirm' && method === 'POST') { + auth.requireAuth(req) + if (!callbacks?.sendSwapCmd) return json({ error: 'Swap UI bridge not wired' }, 503) + // Equivalent to clicking "Confirm Swap" / "Approve & Swap" — kicks off + // executeSwap. The physical device button press still applies. + callbacks.sendSwapCmd({ kind: 'confirm' } as SwapUiCommand) + return json({ data: { ok: true } }) + } + + if (path === '/api/v2/swap/close' && method === 'POST') { + auth.requireAuth(req) + if (!callbacks?.sendSwapCmd) return json({ error: 'Swap UI bridge not wired' }, 503) + callbacks.sendSwapCmd({ kind: 'close' }) + // Belt-and-braces: dispatch the cmd to any mounted dialog AND reset the + // Bun-side cached snapshot. Without this reset, a prior 'submitted' + // state can survive close (no dialog mounted → no unmount publish), and + // subsequent /state reads keep returning the stale failed swap. + const { resetSwapUiState } = await import('./index') + resetSwapUiState() + return json({ data: { ok: true } }) + } + + return null + + } catch (err: any) { + if (err?.status) throw err + console.error(`${TAG} Error on ${path}:`, err.message) + return json({ error: 'Swap API error', details: err.message }, 502) + } +} + +// Reject seeds with unknown asset keys at the REST boundary instead of letting +// SwapDialog silently no-op the lookup. Accepts either the `.asset` form +// ("ETH.ETH") or the `.caip` form ("eip155:1/slip44:60") for forward-compat. +async function validateSeedAssets(body: { fromAsset?: string; toAsset?: string }): Promise<{ error: string; details: { unknownAsset: string; field: 'fromAsset' | 'toAsset' } } | null> { + if (!body.fromAsset && !body.toAsset) return null + const { getSwapAssets } = await import('./swap') + let assets: Awaited> + try { assets = await getSwapAssets() } catch { return null /* don't block on transient asset-list failures */ } + const has = (key: string) => assets.some(a => a.asset === key || a.caip === key) + if (body.fromAsset && !has(body.fromAsset)) return { error: 'Unknown fromAsset', details: { unknownAsset: body.fromAsset, field: 'fromAsset' } } + if (body.toAsset && !has(body.toAsset)) return { error: 'Unknown toAsset', details: { unknownAsset: body.toAsset, field: 'toAsset' } } + return null +} diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index bc777038..7dbc5a6a 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -158,6 +158,118 @@ export const TonSignRequest = z.object({ amount: z.string().optional(), // amount in nanoTON — enables clear-sign on device }).strip() +// ── Message-signing surface (firmware 7.14.1+) ────────────────────── + +// 65-byte recoverable secp256k1 signature, hex with optional 0x prefix. +const HEX_SIG_65 = /^(0x)?[0-9a-fA-F]{130}$/ + +/** POST /tron/sign-message — TIP-191 personal_sign */ +export const TronSignMessageRequest = z.object({ + address_n: z.array(z.number().int()).optional(), + addressNList: z.array(z.number().int()).optional(), + /** Message payload. Default: encoded as UTF-8 bytes. If is_text=false, + * expect hex (with or without 0x prefix). */ + message: z.string().min(1), + /** Default: true. Pass false to send `message` as raw hex bytes instead of UTF-8. */ + is_text: z.boolean().optional(), + show_display: z.boolean().optional(), +}).strip() + +/** POST /tron/verify-message — TIP-191 verify */ +export const TronVerifyMessageRequest = z.object({ + address: z.string().min(1), + /** 65-byte recoverable signature (r || s || v), hex with optional 0x. */ + signature: z.string().regex(HEX_SIG_65), + /** Message payload. Default: encoded as UTF-8 bytes. If is_text=false, + * expect hex (with or without 0x prefix). */ + message: z.string().min(1), + /** Default: true. Pass false to interpret `message` as raw hex bytes instead of UTF-8. */ + is_text: z.boolean().optional(), +}).strip() + +/** POST /tron/sign-typed-hash — TIP-712 hash mode */ +export const TronSignTypedHashRequest = z.object({ + address_n: z.array(z.number().int()).optional(), + addressNList: z.array(z.number().int()).optional(), + /** 32-byte domainSeparator hash, hex (with or without 0x) */ + domain_separator_hash: z.string().regex(/^(0x)?[0-9a-fA-F]{64}$/), + /** 32-byte message hash, hex; omit for primaryType=EIP712Domain */ + message_hash: z.string().regex(/^(0x)?[0-9a-fA-F]{64}$/).optional(), +}).strip() + +/** POST /ton/sign-message — bare Ed25519 (firmware fences behind AdvancedMode) */ +export const TonSignMessageRequest = z.object({ + address_n: z.array(z.number().int()).optional(), + addressNList: z.array(z.number().int()).optional(), + /** Message payload. Default: encoded as UTF-8 bytes. If is_text=false, + * expect hex (with or without 0x prefix). */ + message: z.string().min(1), + /** Default: true. Pass false to send `message` as raw hex bytes instead of UTF-8. */ + is_text: z.boolean().optional(), + show_display: z.boolean().optional(), +}).strip() + +/** POST /solana/sign-offchain-message — domain-separated envelope */ +export const SolanaSignOffchainMessageRequest = z.object({ + address_n: z.array(z.number().int()).optional(), + addressNList: z.array(z.number().int()).optional(), + /** Message payload. Default: encoded as UTF-8 bytes (max 1212 chars). + * If is_text=false, expect hex (max 2424 chars = 1212 bytes; with or + * without 0x prefix). 1212 is the spec ceiling for formats 0 and 1; + * firmware rejects above this anyway, but enforcing here surfaces the + * error pre-USB-roundtrip. */ + message: z.string().min(1).max(2424), + /** Default: true. Pass false to send `message` as raw hex bytes instead of UTF-8. */ + is_text: z.boolean().optional(), + /** Off-chain spec version. Only 0 is currently defined. */ + version: z.number().int().min(0).max(0).optional(), + /** 0 = restricted ASCII, 1 = UTF-8 limited (max 1212 bytes). 2 is rejected device-side. */ + message_format: z.number().int().min(0).max(1).optional(), + show_display: z.boolean().optional(), +}).strip() + +/** + * POST /ton/build-transfer — build an unsigned TON v4R2 transfer. + * Returns the 32-byte body hash (hex) the device should sign plus the + * internal state the vault needs to assemble the signed BOC after. + * Clients don't need BOC/Cell awareness — they just pipe the result's + * `bodyHash` into /ton/sign-transaction and the full build object into + * /ton/finalize-transfer. + * + * `amountNano` is a decimal string (BigInt-compatible) — floats drop + * precision past ~15 digits and a v4R2 transfer can easily exceed that. + * `publicKeyHex` is only needed when the wallet isn't activated yet; + * TON's first tx carries a StateInit that deploys the v4R2 contract. + */ +export const TonBuildTransferRequest = z.object({ + fromAddress: z.string().min(1), + toAddress: z.string().min(1), + amountNano: z.string().min(1), + memo: z.string().optional(), + publicKeyHex: z.string().optional(), +}).strip() + +/** + * POST /ton/finalize-transfer — assemble the signed BOC from a prior + * build + device signature and broadcast to TonCenter. Collapses the + * two-step "assemble then broadcast" flow into one call so clients don't + * have to re-serialize the build object twice over the wire. + * + * `signature` is 64 bytes of Ed25519 output, hex-encoded (matches what + * /ton/sign-transaction returns). + */ +export const TonFinalizeTransferRequest = z.object({ + // tonBuildResult — shape mirrors TonBuildResult in txbuilder/ton.ts. + // We don't re-validate every internal field here because the client + // just echoes back what /ton/build-transfer produced; Zod's passthrough + // preserves _internal verbatim. Any tampering breaks the cellHash + // check implicitly during assembly, which is a safer failure mode than + // a schema diff drifting out of sync with the builder. + build: z.object({}).passthrough(), + signature: z.string().regex(/^[0-9a-fA-F]{128}$/, 'signature must be 64-byte hex'), + broadcast: z.boolean().optional(), +}).strip() + /** POST /solana/sign-message — sign an arbitrary message (firmware type 754) */ export const SolanaSignMessageRequest = z.object({ address_n: z.array(z.number().int()).optional(), @@ -224,9 +336,17 @@ export const ZcashInitRequest = z.object({ account: z.number().int().min(0).optional(), }).passthrough() +/** POST /api/zcash/shielded/display-address */ +export const ZcashDisplayAddressRequest = z.object({ + account: z.number().int().min(0).optional(), +}).passthrough() + /** POST /api/zcash/shielded/scan */ export const ZcashScanRequest = z.object({ start_height: z.number().int().min(0).optional(), + // Discard the cached note set and re-derive from `start_height` (or the + // KeepKey release block when omitted). Used by the "Repair wallet" flow. + full_rescan: z.boolean().optional(), }).passthrough() /** POST /api/zcash/shielded/build */ @@ -360,6 +480,23 @@ export const SwapQuoteRequest = z.object({ slippage: z.number().optional(), }).passthrough() +// ── Swap UI control (REST → SwapDialog) ──────────────────────────────── +// Mirrors SwapUiCommand discriminated union in shared/types.ts. + +const SwapSeedFields = { + fromAsset: z.string().optional(), + toAsset: z.string().optional(), + amount: z.string().optional(), + slippageBps: z.number().int().min(10).max(5000).optional(), + inputMode: z.enum(['crypto', 'fiat']).optional(), + isMax: z.boolean().optional(), + useCustomAddress: z.boolean().optional(), + customToAddress: z.string().optional(), +} + +export const SwapUiOpenRequest = z.object(SwapSeedFields).passthrough() +export const SwapUiSetRequest = z.object(SwapSeedFields).passthrough() + // ═══════════════════════════════════════════════════════════════════════ // Sweep tool schemas // ═══════════════════════════════════════════════════════════════════════ diff --git a/projects/keepkey-vault/src/bun/solana-alt.ts b/projects/keepkey-vault/src/bun/solana-alt.ts new file mode 100644 index 00000000..ba55e185 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-alt.ts @@ -0,0 +1,192 @@ +/** + * Solana Address Lookup Table (ALT) resolver. + * + * V0 transactions reference accounts by (ALT_pubkey, index_in_ALT) instead of + * listing every account inline. Clear-signing needs the real pubkeys to show + * the user what they're signing, so before rendering the instruction list we + * fetch each referenced ALT via Solana's `getMultipleAccounts` RPC and index + * into its address array. + * + * ALT account layout (see + * `solana-sdk/address-lookup-table/program/src/state.rs`): + * + * discriminator : 4 bytes (program-enum tag, 1 = LookupTable, 0 = Uninitialized) + * deactivation_slot : 8 bytes (u64 LE — Slot) + * last_extended_slot : 8 bytes (u64 LE) + * last_extended_slot_start_index : 1 byte + * authority_option : 1 byte (0 = none, 1 = Some) + * authority : 32 bytes (present regardless — zeros when option=0) + * padding : 2 bytes (align to 56) + * addresses : N * 32 bytes (the lookup array itself) + * + * The fixed header is **56 bytes**; addresses follow contiguously to the end + * of the account. Validators enforce that the data length past offset 56 is a + * multiple of 32 — we enforce the same, because a non-multiple would mean the + * ALT is corrupt or we're reading the wrong account type. + * + * Ownership check: a valid ALT account is owned by the Address Lookup Table + * program. Without this check, any account that happens to have the right + * length (56 + 32N) would be accepted and the approval UI would render + * attacker-controlled bytes as "resolved accounts". So we fetch the owner + * alongside the data and reject anything not owned by {@link ALT_PROGRAM_ID}. + * + * We don't validate authority or deactivation_slot in the UI sense — + * decoders are information-only. If deactivation is a concern, a follow-up + * can surface it in the preview. + */ + +import bs58 from 'bs58' + +export const ALT_HEADER_LEN = 56 + +/** Base58 pubkey of Solana's Address Lookup Table program. */ +export const ALT_PROGRAM_ID = 'AddressLookupTab1e1111111111111111111111111' + +/** Discriminator tag for the `LookupTable` variant of the ALT program state. */ +export const ALT_DISCRIMINATOR_LOOKUP_TABLE = 1 + +/** A single account returned by the fetcher. Owner is base58-encoded. */ +export interface AltAccountData { + data: Uint8Array + owner: string +} + +export class SolanaAltResolveError extends Error { + constructor(message: string) { + super(message) + this.name = 'SolanaAltResolveError' + } +} + +/** + * Parse an ALT account's raw bytes into the list of base58-encoded pubkeys + * it stores. Throws {@link SolanaAltResolveError} when the layout doesn't + * match (wrong discriminator, impossible option tag, corrupt trailing + * bytes, etc.). + * + * The caller is responsible for verifying the account owner before calling + * this — see {@link resolveAlts}. + */ +export function parseAltAccountData(bytes: Uint8Array): string[] { + if (bytes.length < ALT_HEADER_LEN) { + throw new SolanaAltResolveError( + `ALT account too short: ${bytes.length}B < ${ALT_HEADER_LEN}B header`, + ) + } + const addrBytes = bytes.length - ALT_HEADER_LEN + if (addrBytes % 32 !== 0) { + throw new SolanaAltResolveError( + `ALT account body length ${addrBytes}B is not a multiple of 32`, + ) + } + // Discriminator is a u32 LE bincode enum tag. LookupTable = 1. + const discriminator = + bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) + if (discriminator !== ALT_DISCRIMINATOR_LOOKUP_TABLE) { + throw new SolanaAltResolveError( + `ALT discriminator ${discriminator} ≠ LookupTable (${ALT_DISCRIMINATOR_LOOKUP_TABLE})`, + ) + } + // authority_option (bincode Option tag) must be 0 or 1 — any other value + // means we're reading the wrong account type. + const authorityOption = bytes[21] + if (authorityOption !== 0 && authorityOption !== 1) { + throw new SolanaAltResolveError( + `ALT authority_option byte ${authorityOption} is not a valid Option tag`, + ) + } + const addresses: string[] = [] + for (let off = ALT_HEADER_LEN; off < bytes.length; off += 32) { + addresses.push(bs58.encode(bytes.subarray(off, off + 32))) + } + return addresses +} + +/** + * Minimal fetcher abstraction so tests can inject a fake RPC responder + * without network. Must return {@link AltAccountData} (including the owner + * pubkey) for each input, in the same order, with `null` where the account + * wasn't found. + */ +export type AltAccountFetcher = (altPubkeysBase58: string[]) => Promise<(AltAccountData | null)[]> + +/** + * Build an `AltAccountFetcher` backed by a Solana JSON-RPC endpoint using + * the standard `getMultipleAccounts` method with base64 encoding. The + * response's `owner` field is returned alongside the data so + * {@link resolveAlts} can reject anything not owned by the ALT program. + */ +export function createRpcAltFetcher(endpoint: string): AltAccountFetcher { + return async (altPubkeysBase58: string[]) => { + if (altPubkeysBase58.length === 0) return [] + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getMultipleAccounts', + params: [altPubkeysBase58, { encoding: 'base64', commitment: 'confirmed' }], + }), + }) + if (!res.ok) { + throw new SolanaAltResolveError(`Solana RPC ${endpoint} returned HTTP ${res.status}`) + } + const body = await res.json() as { + error?: { message: string } + result?: { value?: Array<{ data: [string, string]; owner: string } | null> } + } + if (body.error) { + throw new SolanaAltResolveError(`Solana RPC error: ${body.error.message}`) + } + const value = body.result?.value ?? [] + return value.map((v) => { + if (!v || !v.data) return null + const [b64, encoding] = v.data + if (encoding !== 'base64') { + throw new SolanaAltResolveError(`Unexpected ALT account encoding: ${encoding}`) + } + return { + data: Uint8Array.from(Buffer.from(b64, 'base64')), + owner: v.owner, + } + }) + } +} + +/** + * Resolve a list of ALT pubkeys to their address arrays. Returns a Map + * keyed by base58 ALT pubkey; missing, non-ALT-owned, or malformed ALTs + * are omitted (the caller decides whether to error or surface a warning). + * + * Ownership verification is *required* for safety: without it, a v0 + * transaction could point at any attacker-controlled account whose length + * happens to be 56 + 32N and the approval preview would render attacker + * bytes as "resolved accounts". + */ +export async function resolveAlts( + altPubkeysBase58: string[], + fetcher: AltAccountFetcher, +): Promise> { + const out = new Map() + if (altPubkeysBase58.length === 0) return out + const accounts = await fetcher(altPubkeysBase58) + if (accounts.length !== altPubkeysBase58.length) { + throw new SolanaAltResolveError( + `Fetcher returned ${accounts.length} accounts, expected ${altPubkeysBase58.length}`, + ) + } + for (let i = 0; i < altPubkeysBase58.length; i++) { + const acct = accounts[i] + if (!acct) continue + if (acct.owner !== ALT_PROGRAM_ID) continue + try { + out.set(altPubkeysBase58[i], parseAltAccountData(acct.data)) + } catch { + // Skip malformed entries — caller decides whether a missing ALT is fatal. + } + } + return out +} + +export const DEFAULT_SOLANA_RPC_ENDPOINT = 'https://api.mainnet-beta.solana.com' diff --git a/projects/keepkey-vault/src/bun/solana-clearsign.ts b/projects/keepkey-vault/src/bun/solana-clearsign.ts new file mode 100644 index 00000000..8d0b09ee --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-clearsign.ts @@ -0,0 +1,89 @@ +/** + * End-to-end Solana clear-signing pipeline for the Vault approval dialog. + * + * Input: a raw Solana transaction as the caller sent it to + * /solana/sign-transaction (base64-encoded). + * + * Output: a {@link SolanaTxDecodedInfo} describing each instruction in + * human-readable form, with ALT accounts expanded where possible. + * + * The steps: + * 1. Strip signatures — {@link parseSolanaTx} + {@link solanaMessageSlice} + * 2. Structured message parse — {@link parseSolanaMessage} + * 3. ALT resolution via the caller-supplied fetcher (real RPC in prod, + * injected stub in tests). Best-effort: a failure to resolve marks + * `altResolutionIncomplete` but does not block the preview. + * 4. Account expansion using Solana's canonical resolution order + * (static → ALT-writable → ALT-readonly). + * 5. Per-instruction decoding via the pioneer-discovery program + * registry. + * + * Nothing here talks to the device or the network directly — the caller + * injects the ALT fetcher so this module stays pure for unit tests. + */ + +import bs58 from 'bs58' +import { parseSolanaTx, solanaMessageSlice, parseSolanaMessage } from './solana-tx' +import type { AltAccountFetcher } from './solana-alt' +import { resolveAlts } from './solana-alt' +import { buildExpandedAccounts, decodeInstruction } from './solana-instruction-decoder' +import type { SolanaTxDecodedInfo, SolanaTxDecodedInstruction } from '../shared/types' + +export async function buildSolanaDecodedInfo( + rawTxBase64: string, + altFetcher: AltAccountFetcher, +): Promise { + const fullTx = Buffer.from(rawTxBase64, 'base64') + const parsedTx = parseSolanaTx(fullTx) + const messageBytes = solanaMessageSlice(fullTx, parsedTx) + const parsedMsg = parseSolanaMessage(messageBytes) + + const altPubkeys = parsedMsg.altEntries.map((e) => bs58.encode(e.accountKey)) + let altContents = new Map() + let altResolutionIncomplete = false + if (altPubkeys.length > 0) { + try { + altContents = await resolveAlts(altPubkeys, altFetcher) + if (altContents.size !== altPubkeys.length) altResolutionIncomplete = true + } catch { + altResolutionIncomplete = true + } + } + + const staticAccountsBase58 = parsedMsg.staticAccounts.map((a) => bs58.encode(a)) + const altEntriesForExpand = parsedMsg.altEntries.map((e) => ({ + accountKey: bs58.encode(e.accountKey), + writableIndices: e.writableIndices, + readonlyIndices: e.readonlyIndices, + })) + const { expanded } = buildExpandedAccounts(staticAccountsBase58, altEntriesForExpand, altContents) + + const instructions: SolanaTxDecodedInstruction[] = parsedMsg.instructions.map((ix) => { + const d = decodeInstruction({ + programIdIndex: ix.programIdIndex, + accountIndices: ix.accountIndices, + data: ix.data, + expandedAccounts: expanded, + }) + return { + status: d.status, + programId: d.programId, + programName: d.programName, + programCategory: d.programCategory, + instructionName: d.instructionName, + args: d.args.map((a) => ({ name: a.name, type: a.type, value: a.value })), + accounts: d.accounts.map((a) => ({ label: a.label, pubkey: a.pubkey })), + note: d.note, + } + }) + + const hasUnknownProgram = instructions.some((i) => i.status === 'unknown-program') + + return { + version: parsedMsg.version, + instructions, + altPubkeys, + altResolutionIncomplete: altResolutionIncomplete || undefined, + hasUnknownProgram: hasUnknownProgram || undefined, + } +} diff --git a/projects/keepkey-vault/src/bun/solana-instruction-decoder.ts b/projects/keepkey-vault/src/bun/solana-instruction-decoder.ts new file mode 100644 index 00000000..fbc87dc9 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-instruction-decoder.ts @@ -0,0 +1,359 @@ +/** + * Solana instruction decoder — reads the program registry from + * `@pioneer-platform/pioneer-discovery` and turns raw instruction bytes + * into a human-readable form for the signing-approval UI. + * + * Decoding flow for each instruction: + * + * 1. Resolve `programIdIndex` to a base58 program id via the expanded + * account list (static keys followed by ALT-resolved writable then + * readonly accounts, matching Solana's runtime resolution order). + * 2. Look up the program in the registry. If absent, return an + * `unknown-program` descriptor — the UI still shows the id (truncated) + * and the account/data byte counts so the user knows what they're + * signing even without a rich schema. + * 3. Extract the discriminator from `instruction.data` per the program's + * declared encoding (`u8`, `u32-le`, `anchor` 8-byte sighash, or + * `none`) and look up the instruction schema. + * 4. Walk the declared args over the remaining data bytes and render + * typed values (u64 as decimal, pubkey as base58, string as UTF-8, + * bytes as hex tail). + * 5. Map each schema account label to the resolved base58 key from the + * instruction's `accountIndices`. + * + * Nothing here talks to firmware. The output feeds the Vault-side approval + * dialog, and (in a future PR) the signed-metadata blob sent over + * `SolanaTxMetadata` when firmware ships native v0 support. + */ + +import bs58 from 'bs58' +import { solanaPrograms as solanaProgramsData } from '@pioneer-platform/pioneer-discovery' + +// ── Registry shape ──────────────────────────────────────────────────── + +export type SolanaArgType = 'u8' | 'u16' | 'u32' | 'u64' | 'bool' | 'pubkey' | 'string' | 'bytes' + +export interface SolanaArgSchema { + name: string + type: SolanaArgType +} + +export interface SolanaInstructionSchema { + name: string + description?: string + accounts?: string[] + args?: SolanaArgSchema[] +} + +export type DiscriminatorEncoding = 'u8' | 'u32-le' | 'anchor' | 'none' + +export interface SolanaProgramEntry { + name: string + category: string + website?: string + description?: string + discriminator?: { + encoding: DiscriminatorEncoding + offset?: number + length?: number + } + instructions?: Record +} + +export interface SolanaProgramRegistry { + programs: Record +} + +// The JSON is imported as `any`; narrow it so callers get completion. +export const PROGRAM_REGISTRY: SolanaProgramRegistry = solanaProgramsData as unknown as SolanaProgramRegistry + +// ── Decoded-output types ────────────────────────────────────────────── + +export interface DecodedArg { + name: string + type: SolanaArgType + /** Human-readable value: decimal for numbers, base58 for pubkeys, UTF-8 for strings, hex for bytes. */ + value: string + /** Raw byte range for diagnostics / UI detail panels. */ + rawHex: string +} + +export interface DecodedInstructionAccount { + /** Schema-declared label (e.g. "source", "destination") if available. */ + label?: string + /** base58-encoded pubkey from the expanded account list. */ + pubkey: string + /** Original index into the expanded account list (useful for ALT-origin flags). */ + index: number +} + +export type DecodedInstructionStatus = 'known' | 'known-program-unknown-ix' | 'unknown-program' + +export interface DecodedInstruction { + status: DecodedInstructionStatus + /** base58 program id from the instruction. */ + programId: string + /** Human-readable program name if in the registry; otherwise a truncated program id. */ + programName: string + /** Category from the registry, e.g. "core" / "token" / "nft". Undefined when unknown. */ + programCategory?: string + /** Human-readable instruction name (e.g. "transfer"). Undefined when unknown. */ + instructionName?: string + /** Decoded argument list (empty when not in registry or args can't be decoded). */ + args: DecodedArg[] + /** Account references in the order the program expects them. */ + accounts: DecodedInstructionAccount[] + /** Hex of the discriminator bytes used for lookup (diagnostics). */ + discriminatorHex?: string + /** Any non-fatal note about the decode (e.g. "truncated args after bytes"). */ + note?: string +} + +// ── Discriminator extraction ────────────────────────────────────────── + +function extractDiscriminator(data: Uint8Array, encoding: DiscriminatorEncoding): string | null { + switch (encoding) { + case 'none': return null + case 'u8': + if (data.length < 1) return null + return data[0].toString(16).padStart(2, '0') + case 'u32-le': + if (data.length < 4) return null + return Array.from(data.subarray(0, 4)).map((b) => b.toString(16).padStart(2, '0')).join('') + case 'anchor': + if (data.length < 8) return null + return Array.from(data.subarray(0, 8)).map((b) => b.toString(16).padStart(2, '0')).join('') + } +} + +function discriminatorLength(encoding: DiscriminatorEncoding): number { + switch (encoding) { + case 'none': return 0 + case 'u8': return 1 + case 'u32-le': return 4 + case 'anchor': return 8 + } +} + +// ── Arg decoding ────────────────────────────────────────────────────── + +interface ReadResult { + arg: DecodedArg + nextOffset: number +} + +function readU8(data: Uint8Array, offset: number, name: string): ReadResult { + const v = data[offset] + return { + arg: { name, type: 'u8', value: v.toString(10), rawHex: v.toString(16).padStart(2, '0') }, + nextOffset: offset + 1, + } +} + +function readLe(data: Uint8Array, offset: number, byteCount: number): bigint { + let n = 0n + for (let i = 0; i < byteCount; i++) { + n |= BigInt(data[offset + i]) << BigInt(8 * i) + } + return n +} + +function readU16(data: Uint8Array, offset: number, name: string): ReadResult { + const n = readLe(data, offset, 2) + return { + arg: { name, type: 'u16', value: n.toString(), rawHex: bufToHex(data.subarray(offset, offset + 2)) }, + nextOffset: offset + 2, + } +} + +function readU32(data: Uint8Array, offset: number, name: string): ReadResult { + const n = readLe(data, offset, 4) + return { + arg: { name, type: 'u32', value: n.toString(), rawHex: bufToHex(data.subarray(offset, offset + 4)) }, + nextOffset: offset + 4, + } +} + +function readU64(data: Uint8Array, offset: number, name: string): ReadResult { + const n = readLe(data, offset, 8) + return { + arg: { name, type: 'u64', value: n.toString(), rawHex: bufToHex(data.subarray(offset, offset + 8)) }, + nextOffset: offset + 8, + } +} + +function readBool(data: Uint8Array, offset: number, name: string): ReadResult { + const v = data[offset] + return { + arg: { name, type: 'bool', value: v === 0 ? 'false' : 'true', rawHex: v.toString(16).padStart(2, '0') }, + nextOffset: offset + 1, + } +} + +function readPubkey(data: Uint8Array, offset: number, name: string): ReadResult { + const bytes = data.subarray(offset, offset + 32) + return { + arg: { name, type: 'pubkey', value: bs58.encode(bytes), rawHex: bufToHex(bytes) }, + nextOffset: offset + 32, + } +} + +function readBytesRemaining(data: Uint8Array, offset: number, name: string, type: SolanaArgType): ReadResult { + const bytes = data.subarray(offset) + const value = type === 'string' + ? new TextDecoder('utf-8', { fatal: false }).decode(bytes) + : bufToHex(bytes) + return { + arg: { name, type, value, rawHex: bufToHex(bytes) }, + nextOffset: data.length, + } +} + +function bufToHex(b: Uint8Array): string { + let s = '' + for (const x of b) s += x.toString(16).padStart(2, '0') + return s +} + +function requiredBytes(type: SolanaArgType): number { + switch (type) { + case 'u8': case 'bool': return 1 + case 'u16': return 2 + case 'u32': return 4 + case 'u64': return 8 + case 'pubkey': return 32 + case 'string': case 'bytes': return 0 // consume remaining + } +} + +function decodeArgs(data: Uint8Array, start: number, schema: SolanaArgSchema[]): { args: DecodedArg[]; note?: string } { + const args: DecodedArg[] = [] + let offset = start + for (const arg of schema) { + const need = requiredBytes(arg.type) + if (need > 0 && offset + need > data.length) { + return { args, note: `truncated at arg "${arg.name}" (need ${need}B, have ${data.length - offset}B)` } + } + let res: ReadResult + switch (arg.type) { + case 'u8': res = readU8(data, offset, arg.name); break + case 'u16': res = readU16(data, offset, arg.name); break + case 'u32': res = readU32(data, offset, arg.name); break + case 'u64': res = readU64(data, offset, arg.name); break + case 'bool': res = readBool(data, offset, arg.name); break + case 'pubkey': res = readPubkey(data, offset, arg.name); break + case 'string': res = readBytesRemaining(data, offset, arg.name, 'string'); break + case 'bytes': res = readBytesRemaining(data, offset, arg.name, 'bytes'); break + } + args.push(res.arg) + offset = res.nextOffset + } + return { args } +} + +// ── Main entry point ────────────────────────────────────────────────── + +export interface DecodeInstructionInput { + programIdIndex: number + accountIndices: number[] + data: Uint8Array + /** Expanded account list: static accounts first, then ALT writable, then ALT readonly. */ + expandedAccounts: string[] + /** Optional registry override for tests. Defaults to {@link PROGRAM_REGISTRY}. */ + registry?: SolanaProgramRegistry +} + +export function decodeInstruction(input: DecodeInstructionInput): DecodedInstruction { + const registry = input.registry ?? PROGRAM_REGISTRY + const programId = input.expandedAccounts[input.programIdIndex] ?? '' + const program = registry.programs[programId] + + // Labels are attached below once we know the matching instruction schema. + const accounts: DecodedInstructionAccount[] = input.accountIndices.map((idx) => ({ + pubkey: input.expandedAccounts[idx] ?? '', + index: idx, + })) + + if (!program) { + return { + status: 'unknown-program', + programId, + programName: programId.slice(0, 6) + '…' + programId.slice(-4), + args: [], + accounts, + } + } + + const encoding: DiscriminatorEncoding = program.discriminator?.encoding ?? 'none' + const discHex = extractDiscriminator(input.data, encoding) + const schema = discHex ? program.instructions?.[discHex] : undefined + + if (!schema) { + return { + status: 'known-program-unknown-ix', + programId, + programName: program.name, + programCategory: program.category, + args: [], + accounts, + discriminatorHex: discHex ?? undefined, + note: discHex ? `no schema for discriminator ${discHex}` : 'program has no discriminator encoding', + } + } + + // Label accounts using the schema's ordered labels. + if (schema.accounts) { + for (let i = 0; i < accounts.length && i < schema.accounts.length; i++) { + accounts[i].label = schema.accounts[i] + } + } + + const argStart = discriminatorLength(encoding) + const { args, note } = decodeArgs(input.data, argStart, schema.args ?? []) + + return { + status: 'known', + programId, + programName: program.name, + programCategory: program.category, + instructionName: schema.name, + args, + accounts, + discriminatorHex: discHex ?? undefined, + note, + } +} + +/** + * Build the expanded account list in Solana's canonical v0 resolution + * order: static accounts, then ALT-writable accounts (in ALT order, then + * index order within each), then ALT-readonly accounts. This is the same + * ordering the Solana runtime applies when executing a v0 tx. + */ +export function buildExpandedAccounts( + staticAccountsBase58: string[], + altEntries: Array<{ accountKey: string; writableIndices: number[]; readonlyIndices: number[] }>, + altContents: Map, +): { expanded: string[]; altOrigins: Array<{ from: 'static' | 'alt-writable' | 'alt-readonly'; altPubkey?: string; altIndex?: number }> } { + const expanded = [...staticAccountsBase58] + const altOrigins: Array<{ from: 'static' | 'alt-writable' | 'alt-readonly'; altPubkey?: string; altIndex?: number }> = + staticAccountsBase58.map(() => ({ from: 'static' as const })) + + // ALT writables first (Solana runtime order) + for (const alt of altEntries) { + const contents = altContents.get(alt.accountKey) + for (const idx of alt.writableIndices) { + expanded.push(contents?.[idx] ?? ``) + altOrigins.push({ from: 'alt-writable', altPubkey: alt.accountKey, altIndex: idx }) + } + } + // ALT readonlies after all writables + for (const alt of altEntries) { + const contents = altContents.get(alt.accountKey) + for (const idx of alt.readonlyIndices) { + expanded.push(contents?.[idx] ?? ``) + altOrigins.push({ from: 'alt-readonly', altPubkey: alt.accountKey, altIndex: idx }) + } + } + return { expanded, altOrigins } +} diff --git a/projects/keepkey-vault/src/bun/solana-message-preview.ts b/projects/keepkey-vault/src/bun/solana-message-preview.ts new file mode 100644 index 00000000..8a973b41 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-message-preview.ts @@ -0,0 +1,107 @@ +import bs58 from 'bs58' +import type { SolanaMessageDecodedInfo } from '../shared/types' +import { parseSolanaMessage, parseSolanaTx, solanaMessageSlice } from './solana-tx' + +type SolanaMessageInputEncoding = 'base58' | 'base64' | 'hex' | 'utf8' | 'auto' + +function decodeUtf8(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return undefined + } +} + +function isMostlyReadableText(text: string): boolean { + if (!text) return false + let readable = 0 + for (const ch of text) { + const code = ch.charCodeAt(0) + if (ch === '\n' || ch === '\r' || ch === '\t' || (code >= 0x20 && code !== 0x7f)) { + readable += 1 + } + } + return readable / text.length > 0.85 +} + +function isCanonicalBase64(value: string): boolean { + const compact = value.trim().replace(/\s+/g, '') + if (!compact || compact.length % 4 !== 0) return false + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return false + const bytes = Buffer.from(compact, 'base64') + if (bytes.length === 0) return false + return bytes.toString('base64') === compact +} + +function decodeHexString(value: string): Buffer { + const stripped = value.replace(/^0x/i, '') + const pairs = stripped.match(/.{1,2}/g) || [] + return Buffer.from(pairs.map((byte) => parseInt(byte, 16))) +} + +function decodeMessageBytes(message: string, encoding: SolanaMessageInputEncoding): { + bytes: Buffer + encoding: Exclude +} { + if (encoding === 'base58') return { bytes: Buffer.from(bs58.decode(message)), encoding } + if (encoding === 'base64') return { bytes: Buffer.from(message, 'base64'), encoding } + if (encoding === 'hex') return { bytes: decodeHexString(message), encoding } + if (encoding === 'utf8') return { bytes: Buffer.from(message, 'utf8'), encoding } + + const compact = message.trim() + const hexBody = compact.replace(/^0x/i, '') + if (/^(?:0x)?[0-9a-fA-F]+$/.test(compact)) { + return { bytes: decodeHexString(hexBody), encoding: 'hex' } + } + if (isCanonicalBase64(message)) { + return { bytes: Buffer.from(message.trim(), 'base64'), encoding: 'base64' } + } + return { bytes: Buffer.from(message, 'utf8'), encoding: 'utf8' } +} + +function classifySolanaPayload(bytes: Uint8Array): Pick { + try { + const parsedTx = parseSolanaTx(bytes) + const message = parseSolanaMessage(solanaMessageSlice(bytes, parsedTx)) + return { + classification: 'solana-transaction', + sanityCheck: `Looks like a serialized Solana ${message.version} transaction with ${message.instructions.length} instruction(s).`, + } + } catch { + // Fall through to raw-message check. + } + + try { + const message = parseSolanaMessage(bytes) + return { + classification: 'solana-transaction-message', + sanityCheck: `Looks like a raw Solana ${message.version} transaction message with ${message.instructions.length} instruction(s).`, + } + } catch { + // Fall through to text/binary classification. + } + + const text = decodeUtf8(bytes) + if (text !== undefined && isMostlyReadableText(text)) { + return { classification: 'text-message' } + } + return { classification: 'binary-message' } +} + +export function buildSolanaMessageDecodedInfo( + message: string, + options: { encoding?: SolanaMessageInputEncoding; signer?: string } = {}, +): SolanaMessageDecodedInfo { + const decoded = decodeMessageBytes(message, options.encoding ?? 'auto') + const text = decodeUtf8(decoded.bytes) + const shape = classifySolanaPayload(decoded.bytes) + return { + signer: options.signer, + messageRaw: message, + encoding: decoded.encoding, + messageText: text !== undefined && isMostlyReadableText(text) ? text : undefined, + messageHex: decoded.bytes.toString('hex'), + byteLength: decoded.bytes.length, + ...shape, + } +} diff --git a/projects/keepkey-vault/src/bun/solana-tx.ts b/projects/keepkey-vault/src/bun/solana-tx.ts new file mode 100644 index 00000000..4ee2d9de --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-tx.ts @@ -0,0 +1,319 @@ +/** + * Solana wire-transaction parsing for the Vault signing path. + * + * Wire layout per Solana's `VersionedTransaction::serialize()`: + * + * [compact-u16 sigCount][sigCount * 64 bytes sigs][message] + * + * where `message` is either: + * - a legacy message (`header || static_keys || recent_blockhash || instructions`) — no prefix, OR + * - a versioned message (`0x80 || header || ... || address_lookup_tables`) — starts with 0x80. + * + * Firmware parses the message portion only; we strip sigs and hand it over. + * + * Misconceptions this module defends against: + * 1. Treating the v0 `0x80` prefix as a wrapper byte *before* sigCount. Per + * spec it lives inside the message, after sigs. A transaction whose + * first byte is `>= 0x80` is either this malformed layout or a legacy + * tx with an implausible sigCount ≥ 128 — both refuse explicitly. + * 2. Hand-parsed compact-u16 silently no-op'ing on impossible offsets. The + * original parser would fall through and ship the full buffer to + * firmware, producing a generic "malformed" error far from the cause. + */ + +export interface ParsedSolanaTx { + /** Offset of the first signature byte (end of compact-u16 sigCount). */ + sigStart: number + /** Number of signatures declared by the compact-u16 header. */ + sigCount: number + /** Offset where the message portion starts (after `sigCount * 64` sig bytes). */ + messageStart: number + /** True when the message begins with the v0 prefix (0x80). */ + isVersioned: boolean +} + +/** Real-world Solana transactions have ≤20 signers; keep modest headroom. */ +export const MAX_SIGNATURES = 32 + +export class SolanaTxParseError extends Error { + constructor(message: string) { + super(message) + this.name = 'SolanaTxParseError' + } +} + +/** Parse the wire layout. Throws {@link SolanaTxParseError} on malformed input. */ +export function parseSolanaTx(fullTx: Uint8Array): ParsedSolanaTx { + if (fullTx.length === 0) { + throw new SolanaTxParseError('Empty Solana transaction') + } + + // Reject the `[0x80][sigCount][sigs][msg]` layout some clients incorrectly + // produce. Real wire format puts the version prefix *inside* the message. + // A legitimate legacy tx cannot have `fullTx[0] >= 0x80` either — that + // would imply sigCount ≥ 128, which Solana has no physical path to. + if ((fullTx[0] & 0x80) !== 0) { + throw new SolanaTxParseError( + 'Malformed Solana transaction: first byte has high bit set. ' + + 'Expected compact-u16 signature count; got a value that looks like a ' + + 'versioned-message prefix placed before signatures.', + ) + } + + // Parse LEB128-style compact-u16. In practice always 1 byte (sigCount < 128). + let pos = 0 + let sigCount = 0 + if (fullTx[0] < 0x80) { + sigCount = fullTx[0] + pos = 1 + } else if (fullTx.length >= 2 && fullTx[1] < 0x80) { + sigCount = (fullTx[0] & 0x7f) | (fullTx[1] << 7) + pos = 2 + } else if (fullTx.length >= 3) { + sigCount = (fullTx[0] & 0x7f) | ((fullTx[1] & 0x7f) << 7) | (fullTx[2] << 14) + pos = 3 + } else { + throw new SolanaTxParseError('Malformed Solana transaction: truncated signature count') + } + + if (sigCount < 1 || sigCount > MAX_SIGNATURES) { + throw new SolanaTxParseError( + `Malformed Solana transaction: unreasonable signature count (${sigCount})`, + ) + } + + const messageStart = pos + sigCount * 64 + if (messageStart >= fullTx.length) { + throw new SolanaTxParseError( + 'Malformed Solana transaction: signature section exceeds buffer length', + ) + } + + const isVersioned = (fullTx[messageStart] & 0x80) !== 0 + + return { sigStart: pos, sigCount, messageStart, isVersioned } +} + +/** + * Return the serialized message portion (bytes starting at `messageStart`). + * For legacy messages this is `[header | accounts | blockhash | instructions]`; + * for v0 messages the slice begins with the `0x80` prefix per Solana spec. + * + * These are the exact bytes a signer must sign — Ed25519 is computed over the + * message payload, not the full tx wrapper. The returned slice references the + * input buffer; copy if the caller needs an independent lifetime. + */ +export function solanaMessageSlice(fullTx: Uint8Array, parsed: ParsedSolanaTx): Uint8Array { + return fullTx.subarray(parsed.messageStart) +} + +// ── Structured message parsing ─────────────────────────────────────── +// +// These types + `parseSolanaMessage` walk the message bytes produced by +// `solanaMessageSlice`, turning them into the structured form that the +// clear-signing decoder, UI, and (future) firmware Insight metadata path +// all consume. +// +// Wire layouts are lifted from the Solana SDK's `Message`/`MessageV0` +// serialization (see `solana-sdk/sdk/src/message`). +// +// Legacy message = header(3) || accounts(compact-u16 + N*32) || +// recent_blockhash(32) || +// instructions(compact-u16 + N * Instruction) +// +// V0 message = 0x80 || header(3) || accounts(...) || +// recent_blockhash(32) || instructions(...) || +// alt_entries(compact-u16 + N * AltEntry) +// +// Instruction = program_id_index(u8) || +// account_indices(compact-u16 + N * u8) || +// data(compact-u16 + N * u8) +// +// AltEntry = account_key(32) || +// writable_indices(compact-u16 + N * u8) || +// readonly_indices(compact-u16 + N * u8) + +export interface SolanaMessageHeader { + /** Total number of signers required by the tx. */ + numRequiredSignatures: number + /** Of those signers, how many are readonly (cannot write state). */ + numReadonlySignedAccounts: number + /** Of the non-signer accounts, how many are readonly. */ + numReadonlyUnsignedAccounts: number +} + +export interface SolanaInstruction { + /** Index into the expanded account list (static + ALT). */ + programIdIndex: number + /** Per-instruction account indices; each indexes into the expanded account list. */ + accountIndices: number[] + /** Raw instruction data bytes (caller decodes per program). */ + data: Uint8Array +} + +export interface SolanaAltEntry { + /** 32-byte Address Lookup Table account pubkey (raw bytes — caller base58-encodes for display). */ + accountKey: Uint8Array + /** Indices into the ALT's address array that should be loaded as writable non-signers. */ + writableIndices: number[] + /** Indices into the ALT's address array that should be loaded as readonly non-signers. */ + readonlyIndices: number[] +} + +export interface ParsedSolanaMessage { + /** Wire version — `legacy` (no prefix) or `v0` (0x80 prefix). */ + version: 'legacy' | 'v0' + header: SolanaMessageHeader + /** Raw 32-byte pubkeys of the *static* accounts listed inline in the message. */ + staticAccounts: Uint8Array[] + /** 32-byte recent blockhash. */ + recentBlockhash: Uint8Array + instructions: SolanaInstruction[] + /** ALT entries (empty for legacy). */ + altEntries: SolanaAltEntry[] +} + +/** + * Read a Solana compact-u16 (LEB128-style) at `offset`. Returns `[value, + * nextOffset]`. Real messages almost always use 1 byte per count, but the + * spec allows up to 3 bytes — we handle all three. + */ +function readCompactU16(bytes: Uint8Array, offset: number): [number, number] { + const b0 = bytes[offset] + if (b0 === undefined) throw new SolanaTxParseError('Truncated compact-u16: expected byte 0') + if (b0 < 0x80) return [b0, offset + 1] + const b1 = bytes[offset + 1] + if (b1 === undefined) throw new SolanaTxParseError('Truncated compact-u16: expected byte 1') + if (b1 < 0x80) return [(b0 & 0x7f) | (b1 << 7), offset + 2] + const b2 = bytes[offset + 2] + if (b2 === undefined) throw new SolanaTxParseError('Truncated compact-u16: expected byte 2') + return [(b0 & 0x7f) | ((b1 & 0x7f) << 7) | (b2 << 14), offset + 3] +} + +/** + * Parse a Solana message (the output of {@link solanaMessageSlice}, or any + * standalone message payload) into its structural pieces. Throws + * {@link SolanaTxParseError} on any layout inconsistency. + */ +export function parseSolanaMessage(bytes: Uint8Array): ParsedSolanaMessage { + if (bytes.length < 3) { + throw new SolanaTxParseError('Solana message too short for a header') + } + + let offset = 0 + let version: 'legacy' | 'v0' = 'legacy' + if ((bytes[0] & 0x80) !== 0) { + const ver = bytes[0] & 0x7f + if (ver !== 0) { + throw new SolanaTxParseError(`Unsupported Solana message version: ${ver}`) + } + version = 'v0' + offset = 1 + } + + if (bytes.length < offset + 3) { + throw new SolanaTxParseError('Solana message truncated before header') + } + const header: SolanaMessageHeader = { + numRequiredSignatures: bytes[offset], + numReadonlySignedAccounts: bytes[offset + 1], + numReadonlyUnsignedAccounts: bytes[offset + 2], + } + offset += 3 + + // Static accounts + let staticCount: number + ;[staticCount, offset] = readCompactU16(bytes, offset) + if (staticCount < 1 || staticCount > 256) { + // Solana's account_keys cap is 256 (accounts indexed by u8). + throw new SolanaTxParseError(`Unreasonable static account count: ${staticCount}`) + } + if (offset + staticCount * 32 > bytes.length) { + throw new SolanaTxParseError('Static accounts section exceeds message length') + } + const staticAccounts: Uint8Array[] = [] + for (let i = 0; i < staticCount; i++) { + staticAccounts.push(bytes.subarray(offset, offset + 32)) + offset += 32 + } + + // Recent blockhash (32 bytes) + if (offset + 32 > bytes.length) { + throw new SolanaTxParseError('Message truncated before recent blockhash') + } + const recentBlockhash = bytes.subarray(offset, offset + 32) + offset += 32 + + // Instructions + let ixCount: number + ;[ixCount, offset] = readCompactU16(bytes, offset) + // A single instruction is at minimum 3 bytes (program_id_index(1) + + // accountIndices compact-u16 for 0 (1) + data-length compact-u16 for 0 (1)). + // Anything declaring more instructions than can possibly fit in the + // remaining buffer is malformed — the per-iteration bounds checks below + // would catch it too, but rejecting up-front avoids a pointless loop and + // gives a clearer error. + if (ixCount > (bytes.length - offset) / 3) { + throw new SolanaTxParseError( + `Instruction count ${ixCount} cannot fit in remaining ${bytes.length - offset} bytes`, + ) + } + const instructions: SolanaInstruction[] = [] + for (let i = 0; i < ixCount; i++) { + if (offset + 1 > bytes.length) throw new SolanaTxParseError(`Instruction ${i}: truncated before program_id_index`) + const programIdIndex = bytes[offset] + offset += 1 + let acctCount: number + ;[acctCount, offset] = readCompactU16(bytes, offset) + if (offset + acctCount > bytes.length) throw new SolanaTxParseError(`Instruction ${i}: truncated account indices`) + const accountIndices = Array.from(bytes.subarray(offset, offset + acctCount)) + offset += acctCount + let dataLen: number + ;[dataLen, offset] = readCompactU16(bytes, offset) + if (offset + dataLen > bytes.length) throw new SolanaTxParseError(`Instruction ${i}: truncated data`) + const data = bytes.subarray(offset, offset + dataLen) + offset += dataLen + instructions.push({ programIdIndex, accountIndices, data }) + } + + // ALT entries (v0 only) + const altEntries: SolanaAltEntry[] = [] + if (version === 'v0') { + let altCount: number + ;[altCount, offset] = readCompactU16(bytes, offset) + // Each ALT entry is minimum 34 bytes: account_key(32) + writable + // compact-u16 for 0 (1) + readonly compact-u16 for 0 (1). Same rationale + // as the instruction-count check above: up-front rejection of counts + // that cannot possibly fit avoids a doomed loop. + if (altCount > (bytes.length - offset) / 34) { + throw new SolanaTxParseError( + `ALT count ${altCount} cannot fit in remaining ${bytes.length - offset} bytes`, + ) + } + for (let i = 0; i < altCount; i++) { + if (offset + 32 > bytes.length) throw new SolanaTxParseError(`ALT ${i}: truncated account_key`) + const accountKey = bytes.subarray(offset, offset + 32) + offset += 32 + let wCount: number + ;[wCount, offset] = readCompactU16(bytes, offset) + if (offset + wCount > bytes.length) throw new SolanaTxParseError(`ALT ${i}: truncated writable indices`) + const writableIndices = Array.from(bytes.subarray(offset, offset + wCount)) + offset += wCount + let rCount: number + ;[rCount, offset] = readCompactU16(bytes, offset) + if (offset + rCount > bytes.length) throw new SolanaTxParseError(`ALT ${i}: truncated readonly indices`) + const readonlyIndices = Array.from(bytes.subarray(offset, offset + rCount)) + offset += rCount + altEntries.push({ accountKey, writableIndices, readonlyIndices }) + } + } + + if (offset !== bytes.length) { + throw new SolanaTxParseError( + `Trailing ${bytes.length - offset} unparsed byte(s) in Solana message`, + ) + } + + return { version, header, staticAccounts, recentBlockhash, instructions, altEntries } +} + diff --git a/projects/keepkey-vault/src/bun/swagger.json b/projects/keepkey-vault/src/bun/swagger.json index cc05a6f7..31685755 100644 --- a/projects/keepkey-vault/src/bun/swagger.json +++ b/projects/keepkey-vault/src/bun/swagger.json @@ -3988,6 +3988,104 @@ ] } }, + "/ton/build-transfer": { + "post": { + "operationId": "ton_buildTransfer", + "summary": "Build an unsigned TON v4R2 transfer", + "description": "Fetches the sender's current seqno and wallet-initialization state from TonCenter, then constructs the unsigned body cell for a v4R2 wallet transfer. The returned `bodyHash` is the 32-byte hex string the KeepKey firmware signs via `/ton/sign-transaction`; pass the full `build` object back to `/ton/finalize-transfer` with the signature to assemble and broadcast the BOC. Clients never touch BOC/Cell internals.", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fromAddress": { "type": "string", "description": "Sender TON address (bounceable, non-bounceable, or raw workchain:hex)" }, + "toAddress": { "type": "string", "description": "Recipient TON address" }, + "amountNano": { "type": "string", "description": "Transfer amount in nanoTON (decimal string — BigInt-compatible)" }, + "memo": { "type": "string", "description": "Optional plain-text memo attached to the transfer" }, + "publicKeyHex":{ "type": "string", "description": "Sender ed25519 public key hex — required only for first-time activation (StateInit deploy)" } + }, + "required": ["fromAddress", "toAddress", "amountNano"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "build": { "type": "object", "description": "Opaque build object — echo back to /ton/finalize-transfer unchanged" }, + "bodyHash": { "type": "string", "description": "32-byte hex body-cell hash — pass to /ton/sign-transaction as raw_tx" }, + "rawTx": { "type": "string", "description": "Alias for bodyHash (matches hdwallet's rawTx field)" }, + "seqno": { "type": "number" }, + "expireAt": { "type": "number", "description": "Unix seconds — contract rejects the message past this" }, + "needsDeploy": { "type": "boolean", "description": "True when the wallet has no code on-chain yet; first tx carries StateInit" }, + "feeEstimate": { "type": "string", "description": "Approximate fee in TON (0.005 normal, 0.01 deploy)" } + } + } + } + } + }, + "400": { "description": "Missing publicKeyHex for uninitialized wallet / invalid params" }, + "500": { "description": "Error processing request" }, + "502": { "description": "Upstream TON network error" } + }, + "security": [ { "apiKey": [] } ], + "tags": [ "TON" ] + } + }, + "/ton/finalize-transfer": { + "post": { + "operationId": "ton_finalizeTransfer", + "summary": "Assemble signed BOC from /ton/build-transfer + device signature and broadcast", + "description": "Combines the earlier `build` response with the 64-byte Ed25519 signature returned by `/ton/sign-transaction`, serializes the external message as BOC, and (by default) broadcasts via TonCenter. Pass `broadcast: false` to return the BOC for offline inspection or custom broadcast.", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "build": { "type": "object", "description": "Exact object returned by /ton/build-transfer" }, + "signature": { "type": "string", "description": "64-byte Ed25519 signature as 128-char hex" }, + "broadcast": { "type": "boolean", "description": "Default true; set false to skip TonCenter and return the BOC" } + }, + "required": ["build", "signature"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "boc": { "type": "string", "description": "Base64-encoded signed external message BOC" }, + "txid": { "type": "string", "description": "Hex external-message cell hash — TON's transaction id" }, + "broadcasted": { "type": "boolean" } + } + } + } + } + }, + "400": { "description": "Malformed build object or signature" }, + "500": { "description": "Error processing request" }, + "502": { "description": "TonCenter broadcast failed (BOC + txid preserved in error payload so caller can retry without re-signing)" } + }, + "security": [ { "apiKey": [] } ], + "tags": [ "TON" ] + } + }, "/admin/wallets": { "get": { "operationId": "ListWallets", @@ -4085,13 +4183,58 @@ "application/json": { "schema": { "type": "array", - "items": {} + "items": { + "type": "object", + "properties": { + "busNumber": { "type": "integer", "nullable": true }, + "deviceAddress": { "type": "integer", "nullable": true }, + "portNumbers": { + "type": "array", + "items": { "type": "integer" } + }, + "vendorId": { "type": "integer", "nullable": true }, + "vendorIdHex": { "type": "string", "nullable": true }, + "productId": { "type": "integer", "nullable": true }, + "productIdHex": { "type": "string", "nullable": true }, + "deviceClass": { "type": "integer", "nullable": true }, + "deviceSubClass": { "type": "integer", "nullable": true }, + "deviceProtocol": { "type": "integer", "nullable": true }, + "usbVersion": { "type": "string", "nullable": true }, + "deviceVersion": { "type": "string", "nullable": true }, + "manufacturerIndex": { "type": "integer", "nullable": true }, + "productIndex": { "type": "integer", "nullable": true }, + "serialNumberIndex": { "type": "integer", "nullable": true }, + "isKeepKey": { "type": "boolean" } + }, + "required": [ + "busNumber", + "deviceAddress", + "portNumbers", + "vendorId", + "vendorIdHex", + "productId", + "productIdHex", + "deviceClass", + "deviceSubClass", + "deviceProtocol", + "usbVersion", + "deviceVersion", + "manufacturerIndex", + "productIndex", + "serialNumberIndex", + "isKeepKey" + ] + } } } } } }, - "security": [] + "security": [ + { + "apiKey": [] + } + ] } }, "/admin/usb/state": { @@ -4109,17 +4252,53 @@ "properties": { "connected": { "type": "boolean" + }, + "state": { + "type": "string" + }, + "deviceId": { + "type": "string", + "nullable": true + }, + "label": { + "type": "string", + "nullable": true + }, + "firmwareVersion": { + "type": "string", + "nullable": true + }, + "activeTransport": { + "type": "string", + "nullable": true + }, + "keepKeyOnBus": { + "type": "boolean" + }, + "usbDeviceCount": { + "type": "integer" } }, "required": [ - "connected" + "connected", + "state", + "deviceId", + "label", + "firmwareVersion", + "activeTransport", + "keepKeyOnBus", + "usbDeviceCount" ] } } } } }, - "security": [] + "security": [ + { + "apiKey": [] + } + ] } }, "/admin/info": { @@ -6555,4 +6734,4 @@ } } } -} \ No newline at end of file +} diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 37d11261..7339e8bb 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -6,6 +6,7 @@ */ import { CHAINS } from '../shared/chains' import type { SwapAsset, SwapQuote, RelayTxParams } from '../shared/types' +import { COIN_MAP_LONG } from '@pioneer-platform/pioneer-coins' const TAG = '[swap]' @@ -20,29 +21,45 @@ export function parseThorAsset(asset: string): { chain: string; symbol: string; return { chain, symbol: rest.slice(0, dashIdx), contractAddress: rest.slice(dashIdx + 1) } } -/** Map THORChain chain prefixes to our chain IDs */ +/** Map THORChain chain prefixes to our vault chain IDs. + * + * Source of truth: `@pioneer-platform/pioneer-coins` `COIN_MAP_LONG`. The + * overlay below covers two narrow gaps: + * 1. BSC mismatch — pioneer-coins maps `BSC → 'binance'`, but vault's + * chains.ts uses `'bsc'` as the chain id; we override here. + * 2. Long-form aliases (OPTIMISM, ARBITRUM, BASE, etc.) — ShapeShift + * swapper and ad-hoc paths sometimes emit the long chain name. Without + * these, parseThorAsset throws "Unsupported THORChain chain: OPTIMISM" + * on tokens like VELO routed via ShapeShift. + * 3. TRON alias — THORChain memos use `TRON.TRX`, pioneer-coins only has + * the symbol form `TRX`. */ 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', - XRP: 'ripple', - SOL: 'solana', - TRX: 'tron', - TON: 'ton', + ...COIN_MAP_LONG, + // Vault chain-id overrides (where vault and pioneer-coins disagree) + BSC: 'bsc', + BNB: 'bsc', + // THORChain memo aliases not in pioneer-coins + TRON: 'tron', + // Long-form aliases — defensive against ShapeShift swapper output + ETHEREUM: 'ethereum', + AVALANCHE: 'avalanche', + ARBITRUM: 'arbitrum', + OPTIMISM: 'optimism', + POLYGON: 'polygon', + BITCOIN: 'bitcoin', + LITECOIN: 'litecoin', + DOGECOIN: 'dogecoin', + BITCOINCASH: 'bitcoincash', + COSMOS: 'cosmos', + SOLANA: 'solana', + RIPPLE: 'ripple', } +// VAULT_CHAIN_TO_THOR lives in shared/swap-discovery.ts so both bun and +// frontend can use it without crossing the layer boundary. Re-exported here +// for callers that already import from this module. +export { VAULT_CHAIN_TO_THOR } from '../shared/swap-discovery' + // ── Quote parsing ─────────────────────────────────────────────────── /** @@ -55,7 +72,7 @@ export const THOR_TO_CHAIN: Record = { */ export function parseQuoteResponse( quoteResp: any, - params: { fromAsset: string; toAsset: string; slippageBps?: number }, + params: { fromCaip: string; toCaip: string; slippageBps?: number }, ): SwapQuote { // Pioneer SDK wraps responses: { data: { success, data: [...] } } const qOuter = quoteResp?.data || quoteResp @@ -73,27 +90,83 @@ export function parseQuoteResponse( // 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') + // Underlying protocol for aggregator integrations. ShapeShift surfaces this + // as quote.swapper (and quote.meta.swapper) — e.g. "Relay", "Thorchain", "0x", + // "Uniswap". Falls back to undefined for direct integrations (THORChain, + // ChainFlip) where `integration` already names the protocol. + const swapperRaw = quote.swapper || quote.meta?.swapper || raw.swapper + const swapper = swapperRaw && typeof swapperRaw === 'string' && swapperRaw.toLowerCase() !== 'unknown' + ? swapperRaw + : undefined + + // Extract fields from Pioneer's normalized fields + raw THORNode data. + // Snake_case fallbacks added because Pioneer's Quote response has drifted — + // some integrations now return `expectedAmountOut` / `amount_out` / etc. + const expectedOutput = quote.buyAmount + ?? quote.amountOut + ?? quote.expectedAmountOut + ?? quote.expected_amount_out + ?? quote.amount_out + ?? raw.expected_amount_out + ?? raw.expectedAmountOut + if (expectedOutput == null || expectedOutput === '' || expectedOutput === 0 || expectedOutput === '0') { + // Dump field shapes so we can see exactly what Pioneer returned. This is + // the "expected output coming back 0" path users hit when a pool has no + // liquidity or Pioneer drifts its schema; without this dump the bug is + // invisible in production logs. + console.error(`${TAG} expectedOutput is empty/zero — dumping response structure:`) + console.error(`${TAG} integration: ${integration}`) + 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} first 2KB of best: ${JSON.stringify(best, null, 2).slice(0, 2000)}`) + throw new Error(`No quote output for ${params.fromCaip} → ${params.toCaip} — pool may have no liquidity, or Pioneer schema has drifted (see backend logs for response shape)`) + } const expectedOutputStr = String(expectedOutput) - // ── Relay integration: pre-built tx with calldata, no memo ── - const isRelay = integration === 'relay' + // ── Pre-built calldata integrations (relay, shapeshiftSwap, …) ── + // Any integration that hands us calldata gets the same treatment: we sign + // the supplied tx as-is. The field stays named `relayTx` for backwards + // compatibility with ExecuteSwapParams; conceptually it's "prebuilt tx". + // + // Two sub-cases: + // A) Real calldata (data.length ≥ 10): encodes the swap instruction (Relay, 0x, …) + // B) Deposit-channel (data = '0x' / empty): Chainflip and NEAR Intents EVM-side + // use a plain ETH transfer to a protocol-controlled address; the swap destination + // was registered off-chain when the quote/channel was created. `data` is empty + // intentionally — do NOT conflate with a malformed Relay quote. + const rawData: string | undefined = txParams.data + const hasRealCalldata = !!rawData && rawData !== '0x' && rawData !== '0x0' && rawData.length >= 10 + + // Deposit-channel protocols send a plain native transfer (data = '0x') to a + // protocol-controlled address. The BTC/etc. destination is registered off-chain. + // Allowed list is narrow and explicit — unknown swappers with empty calldata + // are rejected by buildRelaySwapTx's ERC-20 guard if applicable, and warned + // by the pre-existing cross-chain guard. + // Deposit-channel only applies when source is EVM. For UTXO sources (BTC→ETH via + // NEAR Intents), the txParams.to is a Bitcoin address and we use the inboundAddress + // path instead (isMemolessTransfer below). + const fromIsUtxo = params.fromCaip.startsWith('bip122:') + const DEPOSIT_CHANNEL_SWAPPERS = new Set(['Chainflip', 'NEAR Intents']) + const isDepositChannel = !hasRealCalldata && !fromIsUtxo && !!txParams.to && DEPOSIT_CHANNEL_SWAPPERS.has(swapper ?? '') + const hasPrebuiltTx = hasRealCalldata || isDepositChannel let relayTx: RelayTxParams | undefined - if (isRelay && txParams.data) { + if (hasPrebuiltTx) { relayTx = { to: txParams.to, - data: txParams.data, + data: rawData ?? '0x', value: String(txParams.value || '0'), - gasLimit: String(txParams.gasLimit || txParams.gas || '300000'), + // Leave gasLimit undefined when Pioneer omits it so buildRelaySwapTx + // can apply its chain-aware fallback (300000 is only correct for Arbitrum). + gasLimit: (txParams.gasLimit || txParams.gas) ? String(txParams.gasLimit || txParams.gas) : undefined, maxFeePerGas: txParams.maxFeePerGas ? String(txParams.maxFeePerGas) : undefined, maxPriorityFeePerGas: txParams.maxPriorityFeePerGas ? String(txParams.maxPriorityFeePerGas) : undefined, chainId: txParams.chainId, + isDepositChannel: isDepositChannel || undefined, } - console.log(`${TAG} Relay integration — pre-built tx extracted (to=${relayTx.to}, chainId=${relayTx.chainId})`) + console.log(`${TAG} ${integration} (${swapper}) — prebuilt tx extracted (to=${relayTx.to}, depositChannel=${isDepositChannel})`) } // Memo lives in txParams (Pioneer constructs it), fallback to raw @@ -115,13 +188,28 @@ export function parseQuoteResponse( inboundAddress = router } + // Guard: UTXO sources must send to a chain-native address, not an EVM address. + // NEAR Intents BTC→ETH falls back to the user's ETH recipientAddress when Pioneer + // can't surface a BTC deposit address from step.allowanceContract — that ETH + // address would be passed to the firmware as a Bitcoin output and cause + // "Failed to compile output" (code 9). Fail loudly here instead. + if (fromIsUtxo && inboundAddress && inboundAddress.startsWith('0x')) { + console.error(`${TAG} NEAR Intents BTC deposit address is missing — Pioneer returned EVM address ${inboundAddress} as inbound address for a UTXO source. Dumping quote:`) + console.error(`${TAG} txParams keys: ${Object.keys(txParams).join(', ')}`) + console.error(`${TAG} txParams: ${JSON.stringify(txParams, null, 2).slice(0, 2000)}`) + console.error(`${TAG} best keys: ${Object.keys(best).join(', ')}`) + throw new Error('Swap quote did not provide a valid BTC deposit address — NEAR Intents deposit channel may be unavailable for this pair. Try refreshing the quote.') + } + // Expiry for depositWithExpiry const expiry = raw.expiry || quote.expiry || 0 // Native THORChain/Maya swaps (RUNE, CACAO) use MsgDeposit — no inbound vault needed - const isNativeDeposit = params.fromAsset === 'THOR.RUNE' || params.fromAsset === 'MAYA.CACAO' + const isNativeDeposit = + params.fromCaip === 'cosmos:thorchain-mainnet-v1/slip44:931' || + params.fromCaip === 'cosmos:mayachain-mainnet-v1/slip44:931' - if (!inboundAddress && !isNativeDeposit && !isRelay) { + if (!inboundAddress && !isNativeDeposit && !hasPrebuiltTx) { // 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(', ')}`) @@ -131,8 +219,15 @@ export function parseQuoteResponse( console.error(`${TAG} full best: ${JSON.stringify(best, null, 2).slice(0, 2000)}`) throw new Error('Quote response missing inbound address') } - if (!memo && !isRelay) { - console.warn(`${TAG} WARNING: Quote has no memo — tx may fail`) + // For memo-less UTXO swaps (NEAR Intents BTC→ETH): the deposit address IS the + // only instruction — no memo or calldata needed. fromIsUtxo guards direction: + // EVM→BTC with no calldata has no way to encode the BTC destination. + const isMemolessTransfer = fromIsUtxo && !!inboundAddress && swapper === 'NEAR Intents' + if (!memo && !hasPrebuiltTx && !isNativeDeposit && !isMemolessTransfer) { + // A quote with neither memo nor prebuilt calldata has no swap instructions — + // it cannot be executed. Throw now so the UI surfaces a clear error at + // quote-fetch time rather than a cryptic "Missing swap memo" at preview time. + throw new Error('Quote returned no swap instructions (no memo and no calldata) — try a different pair or refresh') } // Extract fees — relay uses a different fee structure @@ -140,7 +235,7 @@ export function parseQuoteResponse( let totalBps = fees.total_bps || fees.totalBps || 0 let outboundFee = fees.outbound || fees.outboundFee || '0' let affiliateFee = fees.affiliate || fees.affiliateFee || '0' - const actualSlippageBps = fees.slippage_bps || fees.slippageBps || (params.slippageBps ?? 300) + const actualSlippageBps = fees.slippage_bps || fees.slippageBps || (params.slippageBps ?? 100) // Minimum output — Pioneer provides amountOutMin, fallback to slippage calc const expectedNum = parseFloat(expectedOutputStr) @@ -155,6 +250,11 @@ export function parseQuoteResponse( const minOutStr = minOut > 0 ? minOut.toFixed(8).replace(/\.?0+$/, '') : '0' + // Minimum sell amount — solvers/protocols may refuse amounts below this floor + // Check multiple field names across the response layers (Pioneer schema varies by swapper) + const minAmountInRaw = quote.minAmountIn ?? best.minAmountIn ?? raw.min_amount_in ?? raw.minAmountIn + const minAmountIn: string | undefined = minAmountInRaw != null ? String(minAmountInRaw) : undefined + return { expectedOutput: expectedOutputStr, minimumOutput: minOutStr, @@ -170,10 +270,10 @@ export function parseQuoteResponse( estimatedTime: Number(estimatedTime), warning: raw.warning || quote.warning || undefined, slippageBps: Number(actualSlippageBps), - fromAsset: params.fromAsset, - toAsset: params.toAsset, integration, + swapper, relayTx, + minAmountIn, } } @@ -208,6 +308,24 @@ export function parseAssetsResponse(resp: any): SwapAsset[] { const isToken = !!parsed.contractAddress + // CAIP is required for tokens — pioneer-server's swap-config controller + // ALWAYS emits it (verified live; the response is keyed on CAIP). If a + // token entry arrives without one, that's a malformed Pioneer response; + // dropping the asset is safer than falling back to the native chain CAIP, + // which would silently quote / attach the wrong asset (e.g. ETH.USDT + // routing as eip155:1/slip44:60 — native ETH). + let caip: string + if (isToken) { + if (!raw.caip) { + console.warn(`[swap] dropping token ${thorAsset} — pioneer-server response missing caip`) + continue + } + caip = raw.caip + } else { + // Native: chainDef.caip is correct by definition (chain native = chain CAIP). + caip = raw.caip || chainDef.caip + } + assets.push({ asset: thorAsset, chainId: ourChainId, @@ -215,7 +333,7 @@ export function parseAssetsResponse(resp: any): SwapAsset[] { name: raw.name || (isToken ? `${parsed.symbol} (${chainDef.coin})` : chainDef.coin), chainFamily: chainDef.chainFamily, decimals: raw.decimals ?? chainDef.decimals, - caip: raw.caip || chainDef.caip, + caip, contractAddress: parsed.contractAddress, icon: raw.icon || raw.image, }) @@ -224,8 +342,25 @@ export function parseAssetsResponse(resp: any): SwapAsset[] { return assets } -/** Convert our chain CAIP + asset info into the CAIP format Pioneer Quote expects */ -export function assetToCaip(thorAsset: string): string { +/** Convert our chain CAIP + asset info into the CAIP format Pioneer Quote expects. + * + * Prefer the canonical caip from the cached SwapAsset list (pioneer is the + * source of truth — it knows that TRON tokens use `/token:T...` with the + * case-sensitive base58 address, while EVM tokens use `/erc20:0x...`). The + * reconstruct path only fires when we don't have a cached asset (e.g. the + * legacy code path passing arbitrary thor-asset strings). + * + * The bug this guards against: reconstructing always emitted `/erc20:` and + * preserved THORChain's uppercase form of the contract address. For TRON + * USDT, that produced `tron:.../erc20:TR7NHQJ...` instead of pioneer's + * canonical `tron:.../token:TR7NHqj...`, and pioneer-router rejected the + * quote with "No quotes available". */ +export function assetToCaip(thorAsset: string, knownAssets?: SwapAsset[]): string { + // Prefer the canonical caip pioneer gave us — it has the right namespace + // (/token: vs /erc20:) and the right case for chains where it matters. + const known = knownAssets?.find(a => a.asset === thorAsset) + if (known?.caip) return known.caip + const parsed = parseThorAsset(thorAsset) const ourChainId = THOR_TO_CHAIN[parsed.chain] if (!ourChainId) throw new Error(`Unsupported THORChain chain: ${parsed.chain}`) @@ -233,10 +368,15 @@ export function assetToCaip(thorAsset: string): string { 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}` + // Token namespace differs by chain family. Without a cached SwapAsset + // we can only pick the right namespace heuristically — and for TRON we + // can't recover the case-sensitive base58 address from THORChain's + // uppercased form, so the result may still be invalid. Callers that + // need TRON tokens should pass `knownAssets` so we hit the canonical + // path above. + const tokenNamespace = chainDef.chainFamily === 'tron' ? 'token' : 'erc20' + return `${chainDef.networkId}/${tokenNamespace}:${parsed.contractAddress}` } // Native asset — use the chain's CAIP-19 diff --git a/projects/keepkey-vault/src/bun/swap-tracker.ts b/projects/keepkey-vault/src/bun/swap-tracker.ts index 4169ed03..241f26b2 100644 --- a/projects/keepkey-vault/src/bun/swap-tracker.ts +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -1,11 +1,13 @@ /** - * Swap Tracker — monitors pending swaps via Pioneer HTTP polling. + * Swap Tracker — pull-only state for in-flight swaps. * - * 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 + * Lifecycle: + * 1. trackSwap() registers a freshly broadcast swap (in-memory + DB) and + * tells Pioneer about it via CreatePendingSwap. + * 2. The SwapDialog drives polling on-demand via refreshSwap(txid) — there + * is no background timer. When the dialog closes, polling stops. + * 3. Status changes get pushed to the UI via the existing swap-update + * RPC message and persisted to SQLite. * * Pioneer operationIds used: * - CreatePendingSwap (POST /swaps/pending) @@ -13,11 +15,92 @@ */ import type { PendingSwap, SwapTrackingStatus, SwapStatusUpdate, SwapResult, ExecuteSwapParams, SwapQuote, SwapHistoryRecord } from '../shared/types' import { getPioneer } from './pioneer' -import { assetToCaip } from './swap-parsing' -import { insertSwapHistory, updateSwapHistoryStatus, getSwapHistory } from './db' +import { withTimeout } from './engine-controller' + +const PIONEER_SWAP_TIMEOUT_MS = 30_000 +import { insertSwapHistory, updateSwapHistoryStatus, getSwapHistory, getSwapHistoryByTxid, setSwapRelayRequestId } from './db' +import { getTxReceiptOnce, EVM_RPC_URLS } from './evm-rpc' +import { assetData as discoveryAssetData } from '@pioneer-platform/pioneer-discovery' +import { VAULT_CHAIN_TO_THOR } from '../shared/swap-discovery' +import { extractRelayRequestId } from '../shared/relay-utils' +import { classifySwapOutcome, type MidgardActionsResponse } from './swap/classify' + +/** Resolve display data from a CAIP-19. CAIP is the only identifier the swap + * layer accepts; symbols / asset names / display names are derived here for + * UI rendering and historic records, never used for routing or selection. + * + * Falls back to a CAIP-derived hint for assets pioneer-discovery doesn't + * know — better than crashing or silently writing empty strings. */ +function resolveDisplayFromCaip(caip: string): { symbol: string; name: string; asset: string } { + const entry = (discoveryAssetData as Record)[caip] + const symbol = entry?.symbol || caip.split('/').pop()?.split(':').pop()?.slice(0, 12).toUpperCase() || 'UNKNOWN' + const name = entry?.name || symbol + // THORChain-style display string (CHAIN.SYMBOL[-CONTRACT]). Used only for + // log lines + history rows; vault never parses this back to identify. + const chainId = entry?.chainId || caip.split('/')[0] + const thorPrefix = VAULT_CHAIN_TO_THOR[chainIdToVaultId(chainId)] || symbol + const tokenMatch = caip.match(/\/(erc20|bep20|token):(.+)$/) + const asset = tokenMatch + ? `${thorPrefix}.${symbol}-${tokenMatch[2].toUpperCase()}` + : `${thorPrefix}.${symbol}` + return { symbol, name, asset } +} + +/** Best-effort CAIP-2 → vault chain id (e.g. 'eip155:1' → 'ethereum'). Used + * by resolveDisplayFromCaip — VAULT_CHAIN_TO_THOR is keyed on vault ids, + * not raw CAIP-2. Safe fallback: return the CAIP-2 itself when unknown. */ +function chainIdToVaultId(caip2: string): string { + // Inline the small mapping rather than importing CHAINS just for this — + // covers every chain that has a THORChain prefix in our lookup. + const map: Record = { + 'bip122:000000000019d6689c085ae165831e93': 'bitcoin', + 'bip122:000000000000000000651ef99cb9fcbe': 'bitcoincash', + 'bip122:00000000001a91e3dace36e2be3bf030': 'dogecoin', + 'bip122:12a765e31ffd4059bada1e25190f6e98': 'litecoin', + 'bip122:000007d91d1254d60e2dd1ae58038307': 'dash', + 'eip155:1': 'ethereum', + 'eip155:10': 'optimism', + 'eip155:56': 'bsc', + 'eip155:137': 'polygon', + 'eip155:8453': 'base', + 'eip155:42161': 'arbitrum', + 'eip155:43114': 'avalanche', + 'cosmos:cosmoshub-4': 'cosmos', + 'cosmos:thorchain-mainnet-v1': 'thorchain', + 'cosmos:mayachain-mainnet-v1': 'mayachain', + 'cosmos:osmosis-1': 'osmosis', + 'tron:0x2b6653dc': 'tron', + 'tron:27Lqcw': 'tron', + 'ton:-239': 'ton', + 'ripple:0': 'ripple', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'solana', + } + return map[caip2] || caip2 +} +import { decideRevertOutcome } from '../shared/swap-revert' +export { decideRevertOutcome } from '../shared/swap-revert' +import { + isTerminalSwapStatus, + mapRelayExecutionStatus, + relayOutboundTxid, + shouldApplyRelayStatus, + type RelayExecutionStatus, +} from '../shared/relay-status' const TAG = '[swap-tracker]' +/** Debug log — gated behind SWAP_DEBUG=1 (env) or localStorage `swap.debug=1`. + * Used in place of console.log for high-volume per-swap chatter. console.warn / + * console.error are deliberately *not* gated — those still ship in prod. */ +const SWAP_DEBUG = ((): boolean => { + try { + if (typeof process !== 'undefined' && process.env?.SWAP_DEBUG === '1') return true + if (typeof localStorage !== 'undefined' && localStorage.getItem('swap.debug') === '1') return true + } catch { /* noop */ } + return false +})() +const swapLog = (...args: any[]): void => { if (SWAP_DEBUG) console.log(...args) } + /** Infer a reasonable confirmation count from persisted status when the DB lacks a confirmations column. * These are conservative lower-bounds — the next poll will replace them with real data. */ export function inferConfirmationsFromStatus(status: SwapTrackingStatus): number { @@ -35,24 +118,26 @@ export function inferConfirmationsFromStatus(status: SwapTrackingStatus): number // ── In-memory swap registry ───────────────────────────────────────── const pendingSwaps = new Map() -const dismissedSwaps = new Set() // prevents race between dismiss and poll // PRIVACY: txids that must not be persisted to DB (passphrase wallet swaps) const noPersistSwaps = new Set() -let pollTimer: ReturnType | null = null +// txids whose registered Pioneer row has been verified (via GetPendingSwap) +// to carry our relayRequestId. Used to stop the lazy re-registration loop — +// without this, every refreshSwap retries CreatePendingSwap forever. +const relayPioneerVerified = new Set() +// Per-txid count of register-relay-id attempts. Caps the retry loop at +// MAX_RELAY_REGISTER_ATTEMPTS so a permanent Pioneer-side rejection (e.g. +// 409 on duplicate txHash with no upsert) doesn't spin forever each time +// the user reopens the dialog. Once the cap is hit, we log loudly and stop +// — the user-visible tracker link still works (it's local), only Pioneer's +// monitor side stays missing the id. +const relayRegisterAttempts = new Map() +const MAX_RELAY_REGISTER_ATTEMPTS = 5 let sendMessage: ((msg: string, data: any) => void) | null = null let pioneerVerified = false let initPromise: Promise | null = null - -/** Adaptive polling: fast at first, backs off as swap ages, gives up after 1 hour */ -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 for 10-20 minutes -const BACKOFF_POLL_MS = 60_000 // 60s after 20 minutes -const FAST_PHASE_MS = 2 * 60_000 // 2 min -const NORMAL_PHASE_MS = 10 * 60_000 // 10 min -const BACKOFF_PHASE_MS = 20 * 60_000 // 20 min — start backing off -const MAX_TRACKING_MS = 60 * 60_000 // 1 hour — give up, user can manually refresh -const COMPLETED_GRACE_MS = 120_000 // keep completed swaps visible for 2 min +let getActiveDeviceId: () => string | undefined = () => undefined +let getActiveWalletId: () => string | undefined = () => undefined +const rehydratedWalletIds = new Set() // Required Pioneer SDK methods — app MUST NOT start without these const REQUIRED_METHODS = ['CreatePendingSwap', 'GetPendingSwap'] as const @@ -64,50 +149,19 @@ export function isTrackerInitialized(): boolean { return sendMessage !== null } -/** Initialize the tracker — verifies Pioneer SDK has required methods. Idempotent: safe to call multiple times. */ -export async function initSwapTracker(messageSender: (msg: string, data: any) => void): Promise { - // Always update the message sender (supports re-init after failure) - sendMessage = messageSender +function rehydrateActiveSwaps(deviceId?: string, walletId?: string): void { + const scopeId = walletId || deviceId + if (!scopeId || rehydratedWalletIds.has(scopeId)) return - // If already verified, just update the sender and return - if (pioneerVerified) return - - // Deduplicate concurrent init calls - if (initPromise) return initPromise - initPromise = (async () => { - // 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) { - 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(', ')})`) - })() - - try { - await initPromise - } finally { - initPromise = null - } - - // 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 }) + const records = getSwapHistory({ status, limit: 50, deviceId, walletId }) for (const r of records) { if (pendingSwaps.has(r.txid)) continue const swap: PendingSwap = { + deviceId: r.deviceId, + walletId: r.walletId, txid: r.txid, fromAsset: r.fromAsset, toAsset: r.toAsset, @@ -115,74 +169,152 @@ export async function initSwapTracker(messageSender: (msg: string, data: any) => toSymbol: r.toSymbol, fromChainId: r.fromChainId, toChainId: r.toChainId, + fromCaip: r.fromCaip, + toCaip: r.toCaip, fromAmount: r.fromAmount, expectedOutput: r.quotedOutput, + receivedOutput: r.receivedOutput, memo: r.memo, inboundAddress: r.inboundAddress, router: r.router, integration: r.integration, + swapper: r.swapper, status: r.status, confirmations: inferConfirmationsFromStatus(r.status), outboundTxid: r.outboundTxid, createdAt: r.createdAt, updatedAt: r.updatedAt, + completedAt: r.completedAt, estimatedTime: r.estimatedTimeSeconds, + slippageBps: r.slippageBps, + relayRequestId: r.relayRequestId, } pendingSwaps.set(r.txid, swap) } } + rehydratedWalletIds.add(scopeId) if (pendingSwaps.size > 0) { - console.log(`${TAG} Rehydrated ${pendingSwaps.size} active swap(s) from SQLite`) - startPolling() + swapLog(`${TAG} Rehydrated active swap(s) for scope ${scopeId}`) } } catch (e: any) { console.warn(`${TAG} Failed to rehydrate swaps from SQLite: ${e.message}`) } } +/** Initialize the tracker — verifies Pioneer SDK has required methods. Idempotent: safe to call multiple times. */ +export async function initSwapTracker(messageSender: (msg: string, data: any) => void, opts?: { getDeviceId?: () => string | undefined; getWalletId?: () => string | undefined }): Promise { + // Always update the message sender (supports re-init after failure) + sendMessage = messageSender + if (opts?.getDeviceId) getActiveDeviceId = opts.getDeviceId + if (opts?.getWalletId) getActiveWalletId = opts.getWalletId + + // If already verified, just update the sender and return + if (pioneerVerified) { + rehydrateActiveSwaps(getActiveDeviceId(), getActiveWalletId()) + return + } + + // Deduplicate concurrent init calls + if (initPromise) return initPromise + initPromise = (async () => { + // 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) { + 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 + swapLog(`${TAG} Tracker initialized — Pioneer SDK verified (${REQUIRED_METHODS.join(', ')})`) + })() + + try { + await initPromise + } finally { + initPromise = null + } + + // Rehydrate active swaps for the connected device only. No polling at boot — + // refreshSwap() drives status updates only when the user opens a swap dialog. + rehydrateActiveSwaps(getActiveDeviceId(), getActiveWalletId()) +} + /** Register a newly broadcast swap for tracking. * @param opts.skipPersist - When true, skip DB writes (PRIVACY: passphrase wallets). */ export function trackSwap( result: SwapResult, params: ExecuteSwapParams, quote: SwapQuote, - opts?: { skipPersist?: boolean }, + opts?: { skipPersist?: boolean; deviceId?: string; walletId?: string }, ): void { const now = Date.now() + const deviceId = opts?.deviceId + const walletId = opts?.walletId + // CAIP is the only identifier the caller provides. Display strings + // (symbol, name, THORChain-style asset) are derived here so UI/history + // can render without a Pioneer round-trip — never used for routing. + const fromDisplay = resolveDisplayFromCaip(params.fromCaip) + const toDisplay = resolveDisplayFromCaip(params.toCaip) + + // Relay deposits embed the request id as the trailing bytes32 of the + // prebuilt calldata. Extract once at sign-time so the resume path / tracker + // link doesn't need a round-trip to api.relay.link for new swaps. + const relayRequestId = extractRelayRequestId(params.relayTx?.data) + const swap: PendingSwap = { + deviceId, + walletId, 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, + fromAsset: fromDisplay.asset, + toAsset: toDisplay.asset, + fromSymbol: fromDisplay.symbol, + toSymbol: toDisplay.symbol, fromChainId: params.fromChainId, toChainId: params.toChainId, + fromCaip: params.fromCaip, + toCaip: params.toCaip, fromAmount: params.amount, expectedOutput: params.expectedOutput, memo: params.memo, inboundAddress: params.inboundAddress, router: params.router, integration: quote.integration || 'thorchain', + swapper: quote.swapper, status: 'pending', confirmations: 0, createdAt: now, updatedAt: now, estimatedTime: quote.estimatedTime, + slippageBps: quote.slippageBps, + relayRequestId, } pendingSwaps.set(result.txid, swap) - console.log(`${TAG} Tracking swap: ${result.txid} (${swap.fromSymbol} → ${swap.toSymbol})`) + swapLog(`${TAG} Tracking swap: ${result.txid} (${swap.fromSymbol} → ${swap.toSymbol})`) - // Persist to SQLite — full lifecycle record + // Persist to SQLite — full lifecycle record. Asset string + symbols are + // derived display fields, populated from the CAIP. CAIP is canonical. const historyRecord: SwapHistoryRecord = { id: crypto.randomUUID(), + deviceId, + walletId, txid: result.txid, - fromAsset: params.fromAsset, - toAsset: params.toAsset, - fromSymbol: swap.fromSymbol, - toSymbol: swap.toSymbol, + fromAsset: fromDisplay.asset, + toAsset: toDisplay.asset, + fromSymbol: fromDisplay.symbol, + toSymbol: toDisplay.symbol, fromChainId: params.fromChainId, toChainId: params.toChainId, + fromCaip: params.fromCaip, + toCaip: params.toCaip, fromAmount: params.amount, quotedOutput: quote.expectedOutput || params.expectedOutput, minimumOutput: quote.minimumOutput || '0', @@ -190,6 +322,7 @@ export function trackSwap( feeBps: quote.fees?.totalBps || 0, feeOutbound: quote.fees?.outbound || '0', integration: quote.integration || 'thorchain', + swapper: quote.swapper, memo: params.memo, inboundAddress: params.inboundAddress, router: params.router, @@ -198,6 +331,7 @@ export function trackSwap( updatedAt: now, estimatedTimeSeconds: quote.estimatedTime || 0, approvalTxid: result.approvalTxid, + relayRequestId, } // PRIVACY: Skip DB write for passphrase wallets — swap still tracked in-memory for UI. if (opts?.skipPersist) { @@ -215,44 +349,53 @@ export function trackSwap( console.error(`${TAG} Stack: ${e.stack}`) }) - // Start polling - startPolling() + // Polling is on-demand — the UI calls refreshSwap(txid) once the dialog + // mounts in the 'submitted' phase. } /** Get all pending swaps (for getPendingSwaps RPC) */ -export function getPendingSwaps(): PendingSwap[] { +export function getPendingSwaps(deviceId?: string, walletId?: string): PendingSwap[] { + rehydrateActiveSwaps(deviceId, walletId) return Array.from(pendingSwaps.values()) + .filter(s => walletId ? s.walletId === walletId : !deviceId || s.deviceId === deviceId) .sort((a, b) => b.createdAt - a.createdAt) } /** Dismiss a swap from the tracker (user clicked dismiss) */ export function dismissSwap(txid: string): void { - dismissedSwaps.add(txid) pendingSwaps.delete(txid) - // Only stop polling when no ACTIVE swaps remain — don't kill polling for other pending swaps - const hasActive = Array.from(pendingSwaps.values()).some(s => - s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' - ) - if (pendingSwaps.size === 0 || !hasActive) { - stopPolling() - } -} - -/** Convert THORChain asset to CAIP, falling back to the raw string on unsupported chains */ -function safeAssetToCaip(thorAsset: string): string { - try { return assetToCaip(thorAsset) } catch { return thorAsset } } // ── Pioneer REST registration ─────────────────────────────────────── +// Pioneer's CreatePendingSwap validator only accepts these integration values +// (see pioneer-server's swagger). Vault internally uses the module-level names +// returned by /api/v1/quote (e.g. "shapeshiftSwap"), so map to the validator +// enum here. Unknown values fall through unchanged — Pioneer will 400 and the +// log will show the mismatch so we can extend this map. +const PIONEER_INTEGRATION_ALIAS: Record = { + shapeshiftSwap: 'shapeshift', +} + async function registerWithPioneer(swap: PendingSwap): Promise { const pioneer = await getPioneer() - const body = { + // CAIP comes straight from the swap record — no asset-string round-trip + // needed. PendingSwap.fromCaip is populated by trackSwap() from the picker's + // selection and is the canonical identifier Pioneer's tracker keys on. + const sellCaip = swap.fromCaip + const buyCaip = swap.toCaip + if (!sellCaip || !buyCaip) { + throw new Error(`registerWithPioneer: missing CAIP (from=${sellCaip}, to=${buyCaip}) for ${swap.txid}`) + } + + const integration = PIONEER_INTEGRATION_ALIAS[swap.integration] || swap.integration + + const body: Record = { txHash: swap.txid, addresses: [], sellAsset: { - caip: safeAssetToCaip(swap.fromAsset), + caip: sellCaip, symbol: swap.fromSymbol, amount: swap.fromAmount, amountBaseUnits: swap.fromAmount, @@ -260,98 +403,107 @@ async function registerWithPioneer(swap: PendingSwap): Promise { networkId: swap.fromChainId, }, buyAsset: { - caip: safeAssetToCaip(swap.toAsset), + caip: buyCaip, symbol: swap.toSymbol, amount: swap.expectedOutput, amountBaseUnits: swap.expectedOutput, - address: '', + // For EVM destinations, the receive address is the ETH address embedded in walletId + // (format: "deviceId:0x..."). Pioneer's swap-monitor needs this to verify ETH delivery. + address: buyCaip.startsWith('eip155:') && swap.walletId?.includes(':') + ? swap.walletId.slice(swap.walletId.indexOf(':') + 1) + : '', networkId: swap.toChainId, }, quote: { id: swap.txid, - integration: swap.integration, + integration, expectedAmountOut: swap.expectedOutput, minimumAmountOut: swap.expectedOutput, slippage: 3, fees: { affiliate: '0', protocol: '0', network: '0' }, memo: swap.memo, }, - integration: swap.integration, + integration, + swapper: swap.swapper, } - - 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 + // Forward the Relay request id when we have one. Pioneer's swap-monitor + // (checkRelaySwap) keys on relayData.requestId; without it Pioneer falls + // back to a confirmation-only watcher that never reaches a terminal status + // for Relay's off-chain settlement model. + if (swap.relayRequestId) { + body.relayData = { requestId: swap.relayRequestId } } - if (oldestAge < FAST_PHASE_MS) return FAST_POLL_MS - if (oldestAge < NORMAL_PHASE_MS) return NORMAL_POLL_MS - if (oldestAge < BACKOFF_PHASE_MS) return SLOW_POLL_MS - return BACKOFF_POLL_MS -} -function startPolling(): void { - if (pollTimer) return - // Poll immediately on start, then schedule next - pollAllSwaps().then(scheduleNextPoll) -} + swapLog(`${TAG} CreatePendingSwap request:`, JSON.stringify({ txHash: body.txHash, sellCaip: body.sellAsset.caip, buyCaip: body.buyAsset.caip, integration: body.integration, swapper: body.swapper })) -/** Schedule next poll using setTimeout (prevents stacking if pollAllSwaps is slow) */ -function scheduleNextPoll(): void { - if (pollTimer) clearTimeout(pollTimer) - // Don't schedule if no active swaps remain - const hasActive = Array.from(pendingSwaps.values()).some(s => - s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' - ) - if (pendingSwaps.size === 0 || !hasActive) { - pollTimer = null - return - } - const interval = getPollInterval() - pollTimer = setTimeout(async () => { - await pollAllSwaps() - scheduleNextPoll() - }, interval) + const resp = await withTimeout(pioneer.CreatePendingSwap(body), PIONEER_SWAP_TIMEOUT_MS, 'CreatePendingSwap') + swapLog(`${TAG} CreatePendingSwap response:`, JSON.stringify(resp?.data || resp)) + swapLog(`${TAG} Registered swap with Pioneer: ${swap.txid}`) } -function stopPolling(): void { - if (pollTimer) { - clearTimeout(pollTimer) - pollTimer = null - console.log(`${TAG} Stopped polling (no active swaps)`) - } -} +// ── On-demand Pioneer fetch ──────────────────────────────────────── -/** Apply remote swap data to local swap, push updates if changed */ +/** Apply remote swap data to local swap, push updates if changed. + * + * If `swap.midgardClassified` is true, Pioneer's status is non-authoritative + * (it cannot tell a refund apart from a completion) and gets ignored — we + * still consume confirmations / timing / fees from Pioneer, but status is + * frozen at whatever Midgard ruled. Without this gate the two sources + * ping-pong on every refresh and burn through Pioneer rate limits. */ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { - const newStatus = mapPioneerStatus(remoteSwap.status) - const confirmations = remoteSwap.confirmations ?? swap.confirmations + // Pioneer's mapped status only takes effect when Midgard hasn't ruled yet. + const pioneerStatus = mapPioneerStatus(remoteSwap.status) + const ignoreNonFinalPioneer = isTerminalSwapStatus(swap.status) && !isTerminalSwapStatus(pioneerStatus) + // Pioneer maps refunds → 'completed' and cannot be trusted to override a locally + // confirmed refund (verified on-chain or via the swap-monitor's eth_getBalance check). + const localRefundOverridesPioneer = swap.status === 'refunded' && pioneerStatus === 'completed' + const newStatus = swap.midgardClassified + ? swap.status + : (ignoreNonFinalPioneer || localRefundOverridesPioneer) + ? swap.status + : pioneerStatus + const confirmations = ignoreNonFinalPioneer ? swap.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] + // Pioneer surfaces the detected underlying protocol in `details.protocol.protocol`. + // If the quote-time parse missed `swapper` (common for aggregator routes where + // ShapeShift's response shape varies), this is the authoritative value. + // + // EXCEPTION: native vault routes (mayachain, thorchain) ARE the swapper — + // there's no underlying aggregator to discover. Maya forked THORChain's code + // and Pioneer's `details.protocol.protocol` reports "thorchain" even for + // Maya pools, which would mis-render as "THORChain via Maya" in the badge. + // Suppress the override so the badge falls back to `integration`. + const isNativeVaultRoute = swap.integration === 'mayachain' || swap.integration === 'thorchain' + const detectedSwapper: string | undefined = isNativeVaultRoute + ? undefined + : (remoteSwap.details?.protocol?.protocol || remoteSwap.swapper || undefined) const errorMsg = remoteSwap.error?.userMessage || remoteSwap.error?.message || (remoteSwap.error ? String(remoteSwap.error) : undefined) const timeEstimate = remoteSwap.timeEstimate + // When Midgard has ruled, Pioneer's outboundTxid is also non-authoritative — + // Pioneer may carry a stale "expected outbound" hash that disagrees with + // the actual on-chain refund/delivery. Locking it here prevents the same + // ping-pong loop status had. + const acceptOutboundTxid = !swap.midgardClassified + + // Stale-swapper cleanup MUST be evaluated as a "change" before the changed + // boolean is computed. Otherwise the cleanup runs but no push/persist + // fires, leaving the DB record (and the resume-render path that seeds + // liveSwapper from it) stuck on "thorchain" forever. + const shouldClearSwapper = isNativeVaultRoute && !!swap.swapper + const changed = newStatus !== swap.status || confirmations !== swap.confirmations || (outboundConfirmations !== undefined && outboundConfirmations !== swap.outboundConfirmations) || - (outboundTxid && outboundTxid !== swap.outboundTxid) + (acceptOutboundTxid && outboundTxid && outboundTxid !== swap.outboundTxid) || + (detectedSwapper && detectedSwapper !== swap.swapper) || + shouldClearSwapper if (changed) { swap.status = newStatus @@ -359,8 +511,10 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { swap.confirmations = confirmations if (outboundConfirmations !== undefined) swap.outboundConfirmations = outboundConfirmations if (outboundRequiredConfirmations !== undefined) swap.outboundRequiredConfirmations = outboundRequiredConfirmations - if (outboundTxid) swap.outboundTxid = outboundTxid + if (acceptOutboundTxid && outboundTxid) swap.outboundTxid = outboundTxid + if (shouldClearSwapper) swap.swapper = undefined if (errorMsg) swap.error = errorMsg + if (detectedSwapper && !swap.swapper) swap.swapper = detectedSwapper if (timeEstimate?.total_swap_seconds && timeEstimate.total_swap_seconds > 0) { swap.estimatedTime = timeEstimate.total_swap_seconds @@ -370,18 +524,28 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { ? remoteSwap.buyAsset.amount : undefined if (receivedOutput) { + swap.receivedOutput = receivedOutput + // Backward-compat: display still reads expectedOutput in some places. swap.expectedOutput = receivedOutput } - console.log(`${TAG} Status change: ${swap.txid} → ${newStatus} (confirmations=${confirmations}, outbound=${outboundConfirmations || 0}/${outboundRequiredConfirmations || '?'}, outTxid=${outboundTxid || 'none'})`) + swapLog(`${TAG} Status change: ${swap.txid} → ${newStatus} (confirmations=${confirmations}, outbound=${outboundConfirmations || 0}/${outboundRequiredConfirmations || '?'}, outTxid=${outboundTxid || 'none'})`) - // Persist status change to SQLite (skip for passphrase wallet swaps) + // Persist status change to SQLite (skip for passphrase wallet swaps). + // Use `swap.outboundTxid` (post-lock truth) NOT the local `outboundTxid` + // extracted from Pioneer — when midgardClassified is set Pioneer's view + // is stale and would otherwise overwrite the DB on the next refresh. + // Same rationale for swapper: pass `null` (not undefined) when we just + // cleared a stale value so the DB column actually clears. const isFinal = newStatus === 'completed' || newStatus === 'failed' || newStatus === 'refunded' const now = Date.now() if (!noPersistSwaps.has(swap.txid)) updateSwapHistoryStatus(swap.txid, newStatus, { - outboundTxid: outboundTxid || undefined, + deviceId: swap.deviceId, + walletId: swap.walletId, + outboundTxid: swap.outboundTxid || undefined, error: errorMsg || undefined, receivedOutput, + swapper: shouldClearSwapper ? null : swap.swapper, completedAt: isFinal ? now : undefined, actualTimeSeconds: isFinal ? Math.round((now - swap.createdAt) / 1000) : undefined, }) @@ -394,86 +558,546 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { } } -async function pollAllSwaps(): Promise { - const now = Date.now() +/** Build an in-memory PendingSwap from a persisted history row. Pure read — + * no side effects. Use this anywhere the caller wants to inspect a stored + * swap without "reactivating" it in the live tracker registry. */ +function readSwapFromDb(txid: string, deviceId?: string, walletId?: string): PendingSwap | null { + const r = getSwapHistoryByTxid(txid, deviceId, walletId) + if (!r) return null + return { + deviceId: r.deviceId, + walletId: r.walletId, + txid: r.txid, + fromAsset: r.fromAsset, toAsset: r.toAsset, + fromSymbol: r.fromSymbol, toSymbol: r.toSymbol, + fromChainId: r.fromChainId, toChainId: r.toChainId, + fromCaip: r.fromCaip, toCaip: r.toCaip, + fromAmount: r.fromAmount, + expectedOutput: r.receivedOutput || r.quotedOutput, + receivedOutput: r.receivedOutput, + memo: r.memo, inboundAddress: r.inboundAddress, router: r.router, + integration: r.integration, swapper: r.swapper, + status: r.status, confirmations: inferConfirmationsFromStatus(r.status), + outboundTxid: r.outboundTxid, + createdAt: r.createdAt, updatedAt: r.updatedAt, completedAt: r.completedAt, + estimatedTime: r.estimatedTimeSeconds, + slippageBps: r.slippageBps, + error: r.error, + relayRequestId: r.relayRequestId, + // Carry the classifier output across resumes so the UI's explorer link + // and refund reason render correctly without waiting for the next poll. + // Implies `midgardClassified=true` if either is set, locking Pioneer's + // status mapping out from regression on the first refresh. + outboundChainId: r.outboundChainId, + refundReason: r.refundReason, + midgardClassified: !!(r.outboundChainId || r.refundReason), + } +} - // Expire swaps older than 1 hour — stop tracking, user can manually refresh - for (const [txid, swap] of pendingSwaps) { - if (swap.status !== 'completed' && swap.status !== 'failed' && swap.status !== 'refunded') { - if (now - swap.createdAt > MAX_TRACKING_MS) { - console.log(`${TAG} Swap ${txid.slice(0, 10)}... exceeded 1h tracking limit — stopping (status was: ${swap.status})`) - swap.status = 'failed' - swap.error = 'Tracking timed out after 1 hour. Use refresh to check status manually.' - swap.updatedAt = now - if (!noPersistSwaps.has(txid)) updateSwapHistoryStatus(txid, 'failed', { error: swap.error }) - pushUpdate(swap) - } - } +/** Hydrate the in-memory swap from the persisted record AND register it in + * the active tracker registry. Use only on paths that intend to refresh / + * push updates for the swap going forward (refreshSwap, getPendingSwaps). + * For diagnostic / read-only paths, call readSwapFromDb directly so the + * tracker registry isn't polluted by an idle history lookup. */ +function hydrateFromDb(txid: string, deviceId?: string, walletId?: string): PendingSwap | null { + const swap = readSwapFromDb(txid, deviceId, walletId) + if (swap) pendingSwaps.set(txid, swap) + return swap +} + +// Vault stores fromChainId in mixed formats — historical 'ethereum', current +// 'eip155:1', or sometimes the bare numeric. Map any of them to the EVM_RPC_URLS +// numeric key. Anything not in this table is treated as non-EVM. +const EVM_CHAIN_TO_NUMERIC: Record = { + ethereum: '1', polygon: '137', arbitrum: '42161', optimism: '10', + avalanche: '43114', bsc: '56', base: '8453', +} +function evmRpcUrlFor(fromChainId: string): string | undefined { + if (!fromChainId) return undefined + // CAIP form: "eip155:1" → "1" + if (fromChainId.startsWith('eip155:')) return EVM_RPC_URLS[fromChainId.slice('eip155:'.length)] + // Legacy slug: "ethereum" → "1" → URL + if (EVM_CHAIN_TO_NUMERIC[fromChainId]) return EVM_RPC_URLS[EVM_CHAIN_TO_NUMERIC[fromChainId]] + // Bare numeric: "1" → URL + return EVM_RPC_URLS[fromChainId] +} + +/** Check the EVM source receipt directly. If status=0x0 the tx reverted on-chain + * (allowance failed, contract revert, etc.) — the swap will NEVER complete and + * the user is just being lied to with "waiting for confirmations". Mark failed + * + push update so the UI flips to a failure state. + * + * Returns true if we definitively flagged the swap as failed (caller should + * short-circuit any further status polling). */ +async function detectEvmRevert(swap: PendingSwap): Promise { + const rpcUrl = evmRpcUrlFor(swap.fromChainId || '') + if (!rpcUrl) return false + try { + const receipt = await getTxReceiptOnce(rpcUrl, swap.txid) + const decision = decideRevertOutcome(swap.status, receipt) + if (!decision) return false + console.warn(`${TAG} EVM source tx REVERTED on-chain: ${swap.txid} (block ${decision.blockNumber}) — marking failed`) + swap.status = decision.status + swap.error = decision.error + swap.updatedAt = Date.now() + try { updateSwapHistoryStatus(swap.txid, 'failed', { deviceId: swap.deviceId, walletId: swap.walletId }) } catch { /* ignore */ } + pushUpdate(swap) + return true + } catch (e: any) { + console.warn(`${TAG} EVM receipt check failed for ${swap.txid.slice(0, 10)}...: ${e.message}`) } + return false +} - const active = Array.from(pendingSwaps.values()).filter(s => - s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' - ) +/** Single on-demand Pioneer poll for one swap. + * Called by the SwapDialog while the user has it open (there is no background + * timer). Returns the latest in-memory swap state, or null if unknown. */ +export async function refreshSwap(txid: string, deviceId?: string, walletId?: string): Promise { + const live = pendingSwaps.get(txid) + let swap = live && (walletId ? live.walletId === walletId : !deviceId || live.deviceId === deviceId) ? live : hydrateFromDb(txid, deviceId, walletId) + if (!swap) { + swapLog(`${TAG} refreshSwap: txid ${txid.slice(0, 10)}... not found in memory or DB`) + return null + } + + // Detect EVM revert FIRST — Pioneer's THORChain/Maya queries return "still + // processing" forever for reverted txs because the protocol never observed + // them. Without this check the user sees "waiting for confirmations" until + // the heat-death of the universe. + if (await detectEvmRevert(swap)) return swap - if (active.length === 0) { - // Clean up completed swaps past grace period - 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) + // Relay request-id backfill is two phases, both retry-safe: + // 1. Local backfill: api.relay.link/requests/v2?hash= once we don't have + // the id locally. Cheap; only fires until swap.relayRequestId is set. + // 2. Pioneer registration: re-post CreatePendingSwap so its checkRelaySwap + // monitor can key on relayData.requestId. Retries on every refreshSwap + // until the next GetPendingSwap response confirms Pioneer reflects our + // id (relayPioneerVerified set after applyRemoteSwapData below). This + // handles the case where Pioneer's CreatePendingSwap is a no-op upsert + // or 409s on the duplicate hash — without verification we'd silently + // give up after one attempt and the original "stuck on confirmation + // watcher" bug would reappear for a subset of swaps. + if (shouldBackfillRelayRequestId(swap)) { + if (!swap.relayRequestId) { + const id = await fetchRelayRequestIdByHash(swap.txid) + if (id) { + swap.relayRequestId = id + try { setSwapRelayRequestId(swap.txid, id, swap.deviceId, swap.walletId) } catch { /* best-effort */ } + pushUpdate(swap) + swapLog(`${TAG} Relay requestId backfilled for ${swap.txid.slice(0, 10)}...: ${id.slice(0, 12)}...`) } } - if (pendingSwaps.size === 0) { - stopPolling() + if (swap.relayRequestId && !relayPioneerVerified.has(swap.txid)) { + const attempts = relayRegisterAttempts.get(swap.txid) || 0 + if (attempts >= MAX_RELAY_REGISTER_ATTEMPTS) { + // Loud one-time log per refresh after we've given up. The local + // tracker link still works; only Pioneer's checkRelaySwap monitor + // is left without the id, which means stuck-status detection on + // the Pioneer side won't resolve until pioneer-server ships an + // explicit UpdatePendingSwap / PATCH endpoint. + if (attempts === MAX_RELAY_REGISTER_ATTEMPTS) { + console.warn(`${TAG} Giving up Pioneer re-registration with Relay id for ${swap.txid.slice(0, 10)}... after ${attempts} attempts — needs a Pioneer-side update endpoint, see PR #152 review thread`) + relayRegisterAttempts.set(swap.txid, attempts + 1) // bump past so this log only fires once + } + } else { + relayRegisterAttempts.set(swap.txid, attempts + 1) + const ok = await setRelayRequestIdOnPioneer(swap) + if (ok) swapLog(`${TAG} Pioneer (re-)registered with relayRequestId for ${swap.txid.slice(0, 10)}... attempt=${attempts + 1} — awaiting verification`) + } } - return + + const relayStatus = await fetchRelayExecutionStatus(swap) + const mappedRelayStatus = mapRelayExecutionStatus(relayStatus?.status) + const relayChanged = applyRelayExecutionStatus(swap, relayStatus) + const hasEnoughRelayTerminalData = hasEnoughRelayTerminalMetadata(swap) + if ( + (relayChanged && isTerminalSwapStatus(swap.status) && hasEnoughRelayTerminalData) || + (mappedRelayStatus && isTerminalSwapStatus(mappedRelayStatus) && mappedRelayStatus === swap.status && hasEnoughRelayTerminalData) + ) return swap } const pioneer = await getPioneer() + try { + const resp = await withTimeout(pioneer.GetPendingSwap({ txHash: txid }), PIONEER_SWAP_TIMEOUT_MS, 'GetPendingSwap') + const remoteSwap = resp?.data || resp + if (!remoteSwap || remoteSwap.status === 'not_found') { + swapLog(`${TAG} refreshSwap ${txid.slice(0, 10)}...: not found in Pioneer yet`) + return swap + } + swapLog(`${TAG} refreshSwap ${txid.slice(0, 10)}...: status=${remoteSwap.status}, confirmations=${remoteSwap.confirmations || 0}`) + applyRemoteSwapData(swap, remoteSwap) - console.log(`${TAG} Polling ${active.length} active swap(s) via GetPendingSwap (per-txHash)...`) + // Pioneer-side relay-id verification. If GetPendingSwap reports our id, + // mark the txid as Pioneer-verified so the lazy re-registration loop + // above stops on the next refresh. Without this verification the loop + // either never stopped (chatty) or stopped after one attempt that may + // have silently failed to land — the original P2 finding. + if (swap.relayRequestId && !relayPioneerVerified.has(swap.txid)) { + const remoteId = (remoteSwap?.relayData?.requestId || '').toLowerCase() + if (remoteId && remoteId === swap.relayRequestId.toLowerCase()) { + relayPioneerVerified.add(swap.txid) + swapLog(`${TAG} Pioneer relay-id verified for ${swap.txid.slice(0, 10)}... — re-registration loop done`) + } + } - // Poll each swap individually — GetPendingSwap uses /swaps/pending/{txHash} - // which doesn't conflict with the SwapHistoryController route - for (const swap of active) { - // Skip swaps dismissed mid-poll cycle (race condition guard) - if (dismissedSwaps.has(swap.txid)) continue - 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 + // If just completed without an outbound txid, request a rescan to pick it up. + if (swap.status === 'completed' && !swap.outboundTxid) { + try { + const rescanResp = await withTimeout(pioneer.GetPendingSwap({ txHash: txid, rescan: true }), PIONEER_SWAP_TIMEOUT_MS, 'GetPendingSwap rescan') + const rescanData = rescanResp?.data || rescanResp + if (rescanData && rescanData.status !== 'not_found') applyRemoteSwapData(swap, rescanData) + } catch (e: any) { + console.warn(`${TAG} refreshSwap rescan failed for ${txid.slice(0, 10)}...: ${e.message}`) } + } - console.log(`${TAG} GetPendingSwap ${swap.txid.slice(0, 10)}...: status=${remoteSwap.status}, confirmations=${remoteSwap.confirmations || 0}`) - - applyRemoteSwapData(swap, remoteSwap) - - // When swap just completed and we don't have outbound txid, do a rescan to get it - if (swap.status === 'completed' && !swap.outboundTxid) { - try { - console.log(`${TAG} Swap completed but no outbound txid — requesting rescan...`) - const rescanResp = await pioneer.GetPendingSwap({ txHash: swap.txid, rescan: true }) - const rescanData = rescanResp?.data || rescanResp - if (rescanData && rescanData.status !== 'not_found') { - applyRemoteSwapData(swap, rescanData) - } - } catch (e: any) { - console.warn(`${TAG} Rescan failed for ${swap.txid.slice(0, 10)}...: ${e.message}`) - } + // ── Midgard truth pass (Maya only — Thor has no public midgard) ── + // Pioneer maps refunds → 'completed' and uses toAsset.chainId for the + // explorer link. Both are wrong for the failure path. Hit Midgard + // directly and let classifySwapOutcome correct the record. + if (swap.integration === 'mayachain') { + const midgard = await fetchMayaMidgardActions(txid) + if (applyClassifiedOutcome(swap, midgard)) { + const isFinal = swap.status === 'completed' || swap.status === 'failed' || swap.status === 'refunded' + if (!noPersistSwaps.has(swap.txid)) updateSwapHistoryStatus(swap.txid, swap.status, { + outboundTxid: swap.outboundTxid, + outboundChainId: swap.outboundChainId, + refundReason: swap.refundReason, + error: swap.error, + completedAt: isFinal ? Date.now() : undefined, + }) + pushUpdate(swap) } + } + + // Pioneer can remain stuck on Relay swaps even after Relay itself has + // marked the request successful. Re-apply Relay's direct status last so a + // stale Pioneer `pending` response cannot downgrade the local tracker. + if (shouldBackfillRelayRequestId(swap)) { + const relayStatus = await fetchRelayExecutionStatus(swap) + applyRelayExecutionStatus(swap, relayStatus) + } + } catch (e: any) { + if (e.status === 404 || e.statusCode === 404 || e.message?.includes('404')) { + swapLog(`${TAG} refreshSwap ${txid.slice(0, 10)}...: not indexed yet (404)`) + } else { + console.error(`${TAG} refreshSwap FAILED for ${txid.slice(0, 10)}...: ${e.message}`) + } + } + return swap +} + +// ── Midgard fallback: source-of-truth for Maya/Thor swap outcomes ── +// +// Pioneer's normalized status maps refunds -> 'completed', and we previously +// keyed the explorer URL on toAsset.chainId. Both are wrong for refunds, where +// the outbound is on the source chain. Midgard's action.type='refund' is +// unambiguous; it also tells us the actual outbound chain via the action.out +// asset. Maya has a working public Midgard at midgard.mayachain.info; Thor's +// Midgard has no live public mirror at the moment so this only covers Maya. +// +// We fetch on every refreshSwap for Maya integrations. Failure to reach +// Midgard is non-fatal: we fall back to Pioneer's view. +const MAYA_MIDGARD_BASE = 'https://midgard.mayachain.info' + +async function fetchMayaMidgardActions(txid: string): Promise { + const normalized = txid.replace(/^0x/i, '').toUpperCase() + const url = `${MAYA_MIDGARD_BASE}/v2/actions?txid=${normalized}` + try { + const resp = await fetch(url, { headers: { accept: 'application/json' } }) + if (!resp.ok) { + swapLog(`${TAG} Maya midgard fetch ${resp.status} for ${txid.slice(0, 10)}...`) + return null + } + return await resp.json() as MidgardActionsResponse + } catch (e: any) { + console.warn(`${TAG} Maya midgard fetch failed for ${txid.slice(0, 10)}...: ${e?.message || e}`) + return null + } +} + +function applyClassifiedOutcome(swap: PendingSwap, midgard: MidgardActionsResponse | null): boolean { + if (!midgard) return false + const outcome = classifySwapOutcome(midgard) + if (outcome.status === 'unknown') return false + + let changed = false + if (outcome.status !== swap.status) { + swap.status = outcome.status + changed = true + } + if (outcome.outboundTxid && outcome.outboundTxid !== swap.outboundTxid) { + swap.outboundTxid = outcome.outboundTxid + changed = true + } + if (outcome.outboundChainId && outcome.outboundChainId !== swap.outboundChainId) { + swap.outboundChainId = outcome.outboundChainId + changed = true + } + if (outcome.refundReason && outcome.refundReason !== swap.refundReason) { + swap.refundReason = outcome.refundReason + changed = true + } + if (!swap.midgardClassified) { + swap.midgardClassified = true + changed = true + } + if (changed) { + swap.updatedAt = Date.now() + swapLog(`${TAG} Midgard reclassified ${swap.txid.slice(0, 10)}... -> status=${outcome.status} outChain=${outcome.outboundChainId || 'n/a'}`) + } + return changed +} + +/** Diagnostic snapshot for a single swap: local state + Pioneer state + + * Pioneer rescan result, with raw responses preserved. Surfaced via the + * `debugSwapLookup` RPC so the user can introspect why a swap is stuck + * without flipping the SWAP_DEBUG flag and re-broadcasting. Read-only — + * does not mutate the in-memory or persisted swap state. + * + * PRIVACY: refuses to operate on passphrase-wallet swaps (txids tagged in + * noPersistSwaps). A passphrase swap stays in `pendingSwaps` for in-session + * UI but must never leak through any read RPC — including from a later + * standard-wallet session in the same vault process. Returns null with no + * Pioneer query so we don't even confirm the txid's existence. */ +export async function debugSwapLookup(txid: string, deviceId?: string, walletId?: string): Promise<{ + txid: string + pioneerBaseUrl: string | undefined + local: PendingSwap | null + pioneer: { ok: boolean; status: number | null; raw: any; error?: string } + pioneerRescan: { ok: boolean; status: number | null; raw: any; error?: string } + divergence?: { vaultProtocol: string; pioneerProtocol: string } +} | null> { + if (noPersistSwaps.has(txid)) return null + // readSwapFromDb (not hydrateFromDb) — debugSwapLookup is read-only and + // must not promote an idle history row back into the active tracker registry. + const live = pendingSwaps.get(txid) + const local = live && (walletId ? live.walletId === walletId : !deviceId || live.deviceId === deviceId) ? live : readSwapFromDb(txid, deviceId, walletId) + if ((walletId || deviceId) && !local) return null + let pioneerBaseUrl: string | undefined + try { + const { getPioneerApiBase } = await import('./pioneer') + pioneerBaseUrl = getPioneerApiBase() + } catch { /* best-effort */ } + + const pioneer = await getPioneer() + const tryCall = async (rescan: boolean) => { + try { + const resp = await withTimeout( + pioneer.GetPendingSwap({ txHash: txid, ...(rescan ? { rescan: true } : {}) }), + PIONEER_SWAP_TIMEOUT_MS, + `GetPendingSwap${rescan ? ' rescan' : ''}`, + ) + const raw = resp?.data || resp + return { ok: true, status: 200, raw } } 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}`) - } + return { ok: false, status: e?.status ?? e?.statusCode ?? null, raw: null, error: e?.message || String(e) } + } + } + const [p1, p2] = await Promise.all([tryCall(false), tryCall(true)]) + + // Surface the protocol-tracking divergence loudly so the user sees the + // exact failure mode: vault registered as X, Pioneer is monitoring as Y. + const detectedProtocol = p1.raw?.details?.protocol?.protocol || p1.raw?.integration + const localProto = (local?.swapper || local?.integration || '').toLowerCase() + const remoteProto = (detectedProtocol || '').toLowerCase() + const divergence = (localProto && remoteProto && !localProto.includes(remoteProto) && !remoteProto.includes(localProto)) + ? { vaultProtocol: localProto, pioneerProtocol: remoteProto } + : undefined + + return { txid, pioneerBaseUrl, local, pioneer: p1, pioneerRescan: p2, divergence } +} + +// ── Relay request-id backfill ─────────────────────────────────────── +// +// Relay's request id (bytes32) keys their public status page and our tracker +// link. trackSwap extracts it from the prebuilt calldata for new swaps; this +// fallback covers legacy rows persisted before that extractor existed and any +// future Relay deposit selector we haven't taught the extractor about yet. +// +// We only attempt it for integrations that route through Relay (relay native, +// or shapeshift's Relay sub-route) and only when the id isn't already on the +// swap. The /requests/v2?hash=... endpoint matches against the inbound tx +// hash directly — no sender lookup or quote-shape parsing needed. + +function shouldBackfillRelayRequestId(swap: PendingSwap): boolean { + const integration = (swap.integration || '').toLowerCase() + // shapeshift may or may not be routing through Relay — the API call is cheap + // and returns nothing for non-Relay swaps, so we let the lookup decide. + if (integration === 'relay' || integration === 'shapeshift' || integration === 'shapeshiftswap') return true + const swapper = (swap.swapper || '').toLowerCase().replace(/[\s_.-]/g, '') + return swapper === 'relay' || swapper === 'relaylink' || swapper === 'relayexchange' +} + +async function fetchRelayRequestIdByHash(txid: string): Promise { + try { + const resp = await fetch( + `https://api.relay.link/requests/v2?hash=${encodeURIComponent(txid)}`, + { signal: AbortSignal.timeout(8000), headers: { accept: 'application/json' } }, + ) + if (!resp.ok) { + swapLog(`${TAG} Relay backfill: HTTP ${resp.status} for ${txid.slice(0, 10)}...`) + return undefined + } + const data = await resp.json() as { requests?: Array<{ id?: string; data?: { inTxs?: Array<{ hash?: string }> } }> } + // Prefer the request whose inTx hash matches exactly — Relay can return + // siblings for the same user when the hash query is partial. + const target = txid.toLowerCase() + const match = (data.requests || []).find(r => + (r.data?.inTxs || []).some(t => (t.hash || '').toLowerCase() === target) + ) || data.requests?.[0] + return match?.id?.toLowerCase() || undefined + } catch (e: any) { + swapLog(`${TAG} Relay backfill failed for ${txid.slice(0, 10)}...: ${e?.message || e}`) + return undefined + } +} + +async function fetchRelayExecutionStatus(swap: PendingSwap): Promise { + if (!swap.relayRequestId) return null + try { + const resp = await fetch( + `https://api.relay.link/intents/status/v3?requestId=${encodeURIComponent(swap.relayRequestId)}`, + { signal: AbortSignal.timeout(8000), headers: { accept: 'application/json' } }, + ) + if (!resp.ok) { + swapLog(`${TAG} Relay status: HTTP ${resp.status} for ${swap.txid.slice(0, 10)}...`) + return null + } + return await resp.json() as RelayExecutionStatus + } catch (e: any) { + swapLog(`${TAG} Relay status failed for ${swap.txid.slice(0, 10)}...: ${e?.message || e}`) + return null + } +} + +function applyRelayExecutionStatus(swap: PendingSwap, relayStatus: RelayExecutionStatus | null): boolean { + if (!relayStatus) return false + const nextStatus = mapRelayExecutionStatus(relayStatus.status) + if (!nextStatus) return false + + const now = Date.now() + const final = isTerminalSwapStatus(nextStatus) + const outboundTxid = relayOutboundTxid(relayStatus, swap.txid) + const relayDetails = relayStatus.details || undefined + const statusChanged = nextStatus !== swap.status + const nextConfirmations = nextStatus !== 'pending' + ? Math.max(swap.confirmations || 0, 1) + : swap.confirmations + const nextOutboundTxid = swap.outboundTxid || outboundTxid + const nextOutboundConfirmations = final && nextStatus === 'completed' && nextOutboundTxid + ? Math.max(swap.outboundConfirmations || 0, 1) + : swap.outboundConfirmations + const nextOutboundRequiredConfirmations = final && nextStatus === 'completed' && nextOutboundTxid + ? Math.max(swap.outboundRequiredConfirmations || 0, 1) + : swap.outboundRequiredConfirmations + const nextError = nextStatus === 'failed' && relayDetails ? relayDetails : swap.error + const nextRefundReason = nextStatus === 'refunded' && relayDetails ? relayDetails : swap.refundReason + const metadataChanged = + nextConfirmations !== swap.confirmations || + (!!outboundTxid && outboundTxid !== swap.outboundTxid) || + nextOutboundConfirmations !== swap.outboundConfirmations || + nextOutboundRequiredConfirmations !== swap.outboundRequiredConfirmations || + nextError !== swap.error || + nextRefundReason !== swap.refundReason + if (!shouldApplyRelayStatus(swap.status, nextStatus, metadataChanged)) return false + + const receivedOutput = nextStatus === 'completed' && swap.receivedOutput === undefined + ? undefined + : swap.receivedOutput + const completedAt = final ? (statusChanged ? now : swap.completedAt || now) : undefined + const actualTimeSeconds = completedAt ? Math.round((completedAt - swap.createdAt) / 1000) : undefined + + swap.status = nextStatus + swap.updatedAt = now + swap.confirmations = nextConfirmations + if (outboundTxid && !swap.outboundTxid) swap.outboundTxid = outboundTxid + if (nextOutboundConfirmations !== undefined) swap.outboundConfirmations = nextOutboundConfirmations + if (nextOutboundRequiredConfirmations !== undefined) swap.outboundRequiredConfirmations = nextOutboundRequiredConfirmations + if (nextStatus === 'failed' && relayDetails) swap.error = nextError + if (nextStatus === 'refunded' && relayDetails) { + swap.refundReason = nextRefundReason + swap.error = relayDetails + } + if (completedAt) swap.completedAt = completedAt + + swapLog(`${TAG} Relay status override: ${swap.txid.slice(0, 10)}... -> ${nextStatus} (relay=${relayStatus.status || 'unknown'}, outTxid=${swap.outboundTxid || 'none'})`) + + if (!noPersistSwaps.has(swap.txid)) updateSwapHistoryStatus(swap.txid, nextStatus, { + deviceId: swap.deviceId, + walletId: swap.walletId, + outboundTxid: swap.outboundTxid || undefined, + error: swap.error, + receivedOutput, + completedAt, + actualTimeSeconds, + refundReason: swap.refundReason, + }) + + pushUpdate(swap) + if (final && statusChanged) pushComplete(swap) + return true +} + +function hasEnoughRelayTerminalMetadata(swap: PendingSwap): boolean { + return swap.status !== 'completed' || !!swap.outboundTxid +} + +/** Push the resolved Relay request id into Pioneer's existing pending-swap row. + * + * Pioneer ships PUT /swaps/pending/{txHash} (operationId UpdatePendingSwap) + * which accepts { relayData: { requestId } }. Caller convention follows + * pioneer-client@>=11.1.0: body is the first arg, path/query the second. + * + * Falls back to CreatePendingSwap when the live client doesn't expose + * UpdatePendingSwap (pioneer-client < 11.1.0 silently dropped PUT bodies, + * so older deploys never made the method usable even though the server + * endpoint existed). On the fallback path Pioneer may 409 on the duplicate + * txHash; we detect that and burn the caller's remaining attempts so the + * bounded loop stops instead of spinning on a permanent rejection. + * + * Returns true if the call completed without throwing (verification happens + * by re-reading GetPendingSwap.relayData.requestId in refreshSwap); false + * on a thrown error. The user-visible relay tracker link works in either + * case since relayRequestId is stored locally first. */ +async function setRelayRequestIdOnPioneer(swap: PendingSwap): Promise { + if (!swap.relayRequestId) return false + try { + const pioneer = await getPioneer() + // Prefer the explicit update endpoint when the loaded swagger exposes + // it. Spec confirmed shipping at api.keepkey.info; method also surfaces + // on PatchPendingSwap and SetPendingSwapRelayData if pioneer-server + // adds aliases later. Forward-compat at zero cost. + const updateMethod = ['UpdatePendingSwap', 'PatchPendingSwap', 'SetPendingSwapRelayData'] + .find(name => typeof (pioneer as any)?.[name] === 'function') + if (updateMethod) { + await (pioneer as any)[updateMethod]( + { relayData: { requestId: swap.relayRequestId } }, + { txHash: swap.txid }, + ) + swapLog(`${TAG} Pioneer ${updateMethod} succeeded for ${swap.txid.slice(0, 10)}...`) + return true + } + // Fallback: re-post CreatePendingSwap with the full body. This works + // only if pioneer-server happens to upsert on duplicate txHash; if it + // 409s the catch below burns remaining retries. + await registerWithPioneer(swap) + return true + } catch (e: any) { + const msg = String(e?.message || e || '') + const isDuplicate = e?.status === 409 + || e?.statusCode === 409 + || /already exists|duplicate|409/i.test(msg) + if (isDuplicate) { + console.warn(`${TAG} Pioneer rejected Relay-id (re-)registration for ${swap.txid.slice(0, 10)}... as duplicate (${msg.slice(0, 80)}). Pioneer needs an UpdatePendingSwap endpoint — opening a tracking issue on pioneer-server.`) + // Burn the remaining attempts so the caller's bounded loop stops on + // the next refresh — there's no recovery from a permanent 409 without + // a Pioneer-side change. + relayRegisterAttempts.set(swap.txid, MAX_RELAY_REGISTER_ATTEMPTS) + } else { + console.warn(`${TAG} Pioneer Relay-id registration call failed for ${swap.txid.slice(0, 10)}...: ${msg.slice(0, 200)}`) } + return false } } @@ -506,13 +1130,17 @@ function pushUpdate(swap: PendingSwap): void { outboundRequiredConfirmations: swap.outboundRequiredConfirmations, outboundTxid: swap.outboundTxid, error: swap.error, + swapper: swap.swapper, + relayRequestId: swap.relayRequestId, + outboundChainId: swap.outboundChainId, + refundReason: swap.refundReason, } - console.log(`${TAG} Pushing swap-update: ${swap.txid} status=${swap.status} confirmations=${swap.confirmations}`) + swapLog(`${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}`) + swapLog(`${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 index 3c4259cd..d95fbdd5 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -8,19 +8,72 @@ * * NO direct THORNode or other third-party calls — fail fast if Pioneer is down. */ -import { CHAINS } from '../shared/chains' +import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath } from '../shared/chains' import type { ChainDef } from '../shared/chains' import type { SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult } from '../shared/types' import { getPioneer } from './pioneer' import { encodeDepositWithExpiry, encodeApprove, parseUnits, toHex } from './txbuilder/evm' -import { getEvmGasPrice, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20Decimals, broadcastEvmTx, waitForTxReceipt, estimateGas } from './evm-rpc' +import { getEvmGasPrice, getEvmFeeData, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20Balance, getErc20Decimals, broadcastEvmTx, waitForTxReceipt, estimateGas } from './evm-rpc' import * as txb from './txbuilder' // Re-export pure parsing functions (used by tests + this module) -export { parseQuoteResponse, parseAssetsResponse, parseThorAsset, assetToCaip, THOR_TO_CHAIN } from './swap-parsing' -import { parseQuoteResponse, parseAssetsResponse, assetToCaip } from './swap-parsing' +// assetToCaip is exported for backwards-compat (legacy code that hydrates old +// history rows without CAIP). The swap quote/execute path no longer uses it — +// vault is CAIP-native end-to-end. +export { parseAssetsResponse, parseQuoteResponse, assetToCaip } from './swap-parsing' +import { parseQuoteResponse, parseAssetsResponse } from './swap-parsing' const TAG = '[swap]' +// CAIP-2 of Bitcoin mainnet (genesis-hash-keyed, canonical, immutable). +// Used to gate BTC-only behavior — the cached btc-account-manager and the +// per-scriptType lazy derive only apply to BTC. Comparing against this +// constant is durable across renames of `ChainDef.id`; matches the same +// constant defined in sweep-engine.ts and rest-sweep.ts. +const BTC_NETWORK_ID = 'bip122:000000000019d6689c085ae165831e93' +const isBitcoin = (c: ChainDef) => c.networkId === BTC_NETWORK_ID + +// CAIP-19 of native THORChain (RUNE) and Mayachain (CACAO) — the only assets +// that route via MsgDeposit instead of a vault inbound address. CAIP is the +// canonical identifier; symbols and THOR-style asset strings are derived +// display data, never load-bearing. +const RUNE_CAIP = 'cosmos:thorchain-mainnet-v1/slip44:931' +const CACAO_CAIP = 'cosmos:mayachain-mainnet-v1/slip44:931' + +/** True for native THORChain/Maya deposits (CAIP-driven; replaces the + * fragile `fromAsset === 'THOR.RUNE'` check that depended on canonical + * THORChain prefix). */ +function isNativeDepositCaip(fromCaip: string): boolean { + return fromCaip === RUNE_CAIP || fromCaip === CACAO_CAIP +} + +// CAIP namespace parser is shared with the picker so a future namespace +// addition (Solana SPL, etc.) only needs editing in one place. +import { parseCaip } from '../shared/swap-discovery' + +/** True for ERC-20 / BEP-20 / TRC-20 token sources. */ +function isTokenCaip(caip: string): boolean { + return parseCaip(caip).isToken +} + +/** Extract the contract/token address from a CAIP-19, or null for native + * assets. Returns the raw form (CAIP preserves source case — EVM lowercase, + * Tron base58 case-sensitive). Caller is responsible for case normalization. */ +function extractContractFromCaip(caip: string): string | null { + return parseCaip(caip).contractAddress ?? null +} + +/** Debug log — gated behind SWAP_DEBUG=1 (env) or localStorage `swap.debug=1`. + * Used in place of console.log for high-volume per-swap chatter. console.warn / + * console.error are deliberately *not* gated — those still ship in prod. */ +const SWAP_DEBUG = ((): boolean => { + try { + if (typeof process !== 'undefined' && process.env?.SWAP_DEBUG === '1') return true + if (typeof localStorage !== 'undefined' && localStorage.getItem('swap.debug') === '1') return true + } catch { /* noop */ } + return false +})() +const swapLog = (...args: any[]): void => { if (SWAP_DEBUG) console.log(...args) } + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' /** Format a bigint wei value as a human-readable string (avoids Number() precision loss for large values) */ @@ -32,15 +85,20 @@ function formatWei(wei: bigint, decimals = 18): string { } /** Chain-aware minimum gas price floors (gwei) — enforced even when RPC/Pioneer report lower. - * L2s and less-used chains frequently report unrealistically low fees that cause mempool drops. */ + * L2s and less-used chains frequently report unrealistically low fees that cause mempool drops. + * ETH mainnet floor raised from 1 → 3: 1 gwei txs sit in mempool on busy days and time out. + * L2 floors raised so legacy-gas fallback path doesn't ship sub-base-fee txs. */ const MIN_GAS_GWEI: Record = { - ethereum: 1, + ethereum: 3, polygon: 30, avalanche: 25, bsc: 3, - base: 1, - arbitrum: 1, - optimism: 1, + base: 0.05, // L2 base fees are sub-gwei; floor still must beat them + arbitrum: 0.1, + optimism: 0.05, + gnosis: 2, + monad: 50, + hyperliquid: 0.1, } /** Chain-aware gas limits for depositWithExpiry — L2s need more for L1 data posting */ @@ -60,51 +118,10 @@ const DEPOSIT_GAS_LIMITS: Record = { * from Pioneer/THORNode and only enforce the THORChain protocol limit. */ const MEMO_LIMIT = 250 -// ── Router validation via Pioneer ─────────────────────────────────── -// Pioneer proxies THORNode inbound_addresses — validate router from quote -// matches what Pioneer reports, to guard against stale/tampered quotes. - -let routerCache: { routers: Map; ts: number } = { routers: new Map(), ts: 0 } -const ROUTER_CACHE_TTL = 5 * 60_000 // 5 minutes - -const CHAIN_TO_THORNODE: Record = { - ethereum: 'ETH', avalanche: 'AVAX', bsc: 'BSC', polygon: 'MATIC', - base: 'BASE', arbitrum: 'ARB', optimism: 'OP', -} - -async function validateRouterAddress(router: string, chain: ChainDef, pioneer: any): Promise { - const thorChain = CHAIN_TO_THORNODE[chain.id] - if (!thorChain) return // non-EVM or unmapped chains - - // Check cache first - if (routerCache.routers.size > 0 && Date.now() - routerCache.ts < ROUTER_CACHE_TTL) { - const expected = routerCache.routers.get(thorChain) - if (expected && router.toLowerCase() !== expected) { - throw new Error(`Router mismatch: quote=${router}, Pioneer inbound=${expected} for ${thorChain}. Swap aborted.`) - } - return - } - - // Fetch inbound addresses from Pioneer - try { - const resp = await pioneer.GetInboundAddresses() - const data: Array<{ chain: string; router?: string }> = resp?.data || resp || [] - const routers = new Map() - for (const entry of data) { - if (entry.router) routers.set(entry.chain, entry.router.toLowerCase()) - } - routerCache = { routers, ts: Date.now() } - - const expected = routers.get(thorChain) - if (expected && router.toLowerCase() !== expected) { - throw new Error(`Router mismatch: quote=${router}, Pioneer inbound=${expected} for ${thorChain}. Swap aborted.`) - } - } catch (e: any) { - if (e.message?.includes('Router mismatch')) throw e - // Pioneer unavailable — log warning but don't block the swap - console.warn(`${TAG} Could not validate router via Pioneer: ${e.message}`) - } -} +// Router/inbound-address validation belongs in pioneer-router (which runs +// each integration's own checks before returning a quote). The vault trusts +// the router string Pioneer hands back. Kept here as a comment so future +// readers don't reintroduce a redundant check on the client side. // ── Pool/Asset fetching via Pioneer ───────────────────────────────── @@ -125,7 +142,7 @@ export async function getSwapAssets(): Promise { } const pioneer = await getPioneer() - console.log(`${TAG} Fetching available swap assets from Pioneer...`) + swapLog(`${TAG} Fetching available swap assets from Pioneer...`) const resp = await pioneer.GetAvailableAssets() const assets = parseAssetsResponse(resp) @@ -146,7 +163,42 @@ export async function getSwapAssets(): Promise { } } - console.log(`${TAG} Loaded ${assets.length} swap assets from Pioneer`) + // Pioneer's /swap/available-assets historically omitted TRON entirely + // even though /quote happily quotes TRON.TRX and TRON.USDT-TR7N... via + // THORChain (both pools verified Available against thornode). Pioneer PR + // #37 fixed this for both, but if a future deploy re-includes only one + // (different whitelist policy, drift, etc.) the all-or-nothing shim + // below would silently drop the other. Check each asset independently + // so partial pioneer coverage doesn't regress us. Same posture as the + // THOR.RUNE entry above. + const tronDef = CHAINS.find(c => c.id === 'tron') + if (tronDef) { + if (!assets.find(a => a.asset === 'TRON.TRX')) { + assets.push({ + asset: 'TRON.TRX', + chainId: 'tron', + symbol: 'TRX', + name: 'Tron', + chainFamily: 'tron', + decimals: 6, + caip: tronDef.caip, + }) + } + if (!assets.find(a => a.asset === 'TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T')) { + assets.push({ + asset: 'TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T', + chainId: 'tron', + symbol: 'USDT', + name: 'Tether (TRON)', + chainFamily: 'tron', + decimals: 6, + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + caip: 'tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + }) + } + } + + swapLog(`${TAG} Loaded ${assets.length} swap assets from Pioneer`) assetCache = assets assetCacheTime = Date.now() return assets @@ -160,12 +212,24 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise throw new Error('Amount must be greater than 0') } + // Slippage policy: 0 is rejected (no protection = funds at risk on any + // volatile pair). Anything outside [10, 5000] bps is clamped to that range. + // Default to 100 bps (1%) when caller omits the field. + if (params.slippageBps === 0) { + throw new Error('Slippage of 0 is not allowed — choose a tolerance between 0.1% and 50%') + } + const slippageBps = params.slippageBps == null + ? 100 + : Math.max(10, Math.min(5000, Math.round(params.slippageBps))) + 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 + // Pioneer Quote takes CAIP directly — no THORChain-asset round-trip needed. + // The legacy `fromAsset` / `toAsset` strings are still carried for tracking + // + display, but vault no longer parses them to derive identity. + const sellCaip = params.fromCaip + const buyCaip = params.toCaip + const slippage = slippageBps / 100 // Pioneer uses % not bps // Normalize BCH CashAddr: strip "bitcoincash:" prefix — THORChain uses short form const normalizeBchAddr = (addr: string) => @@ -173,26 +237,80 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise const senderAddress = normalizeBchAddr(params.fromAddress) const recipientAddress = normalizeBchAddr(params.toAddress) - console.log(`${TAG} Fetching quote: ${params.fromAsset} → ${params.toAsset} (${params.amount})`) - console.log(`${TAG} CAIP: ${sellCaip} → ${buyCaip}`) - console.log(`${TAG} sender=${senderAddress}, recipient=${recipientAddress}`) - - const quoteResp = await pioneer.Quote({ - sellAsset: sellCaip, - sellAmount: params.amount, // Pioneer expects DECIMAL format (human-readable) - buyAsset: buyCaip, - recipientAddress, - senderAddress, - slippage, - }) + swapLog(`${TAG} Fetching quote: ${params.fromCaip} → ${params.toCaip} (${params.amount})`) + swapLog(`${TAG} CAIP: ${sellCaip} → ${buyCaip}`) + swapLog(`${TAG} sender=${senderAddress}, recipient=${recipientAddress}`) + + let quoteResp: any + try { + quoteResp = await pioneer.Quote({ + sellAsset: sellCaip, + sellAmount: params.amount, // Pioneer expects DECIMAL format (human-readable) + buyAsset: buyCaip, + recipientAddress, + senderAddress, + slippage, + }) + } catch (e: any) { + // Pioneer-client (swagger-client) throws Error with message="Internal + // Server Error" but the real diagnostic from THORNode (e.g. "amount less + // than min swap amount (recommended_min_amount_in: …)") is in + // e.response.body.message. Surface the inner message so the RPC layer + + // frontend can render something useful instead of "Internal Server Error". + const inner = e?.response?.body?.message || e?.responseError?.message || e?.response?.text + if (inner && typeof inner === 'string') { + // The text body comes through as a JSON string on some swagger versions; + // try to unwrap once if it looks like JSON. + let unwrapped = inner + try { + const parsed = JSON.parse(inner) + if (parsed?.message) unwrapped = parsed.message + } catch { /* not JSON, use as-is */ } + const err = new Error(unwrapped) + ;(err as any).cause = e + throw err + } + throw e + } // 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'}`) + swapLog(`${TAG} Raw quote response keys: ${firstQuote ? Object.keys(firstQuote).join(', ') : 'EMPTY'}`) + + // Pass the validated/clamped slippageBps so parseQuoteResponse's fallback + // calc lines up with what was actually requested upstream. + const result = parseQuoteResponse(quoteResp, { ...params, slippageBps }) + // Surface the underlying protocol when the integration is an aggregator + // (e.g. ShapeShift → Relay/Thorchain/0x). Falls back to integration name. + const route = result.swapper && result.swapper.toLowerCase() !== (result.integration || '').toLowerCase() + ? `${result.swapper} via ${result.integration}` + : (result.integration || 'unknown') + swapLog(`${TAG} Quote: ${result.expectedOutput} (${route}), memo=${result.memo || 'NONE'}, router=${result.router || 'NONE'}, expiry=${result.expiry}`) + + // NEAR Intents BTC→EVM: competitive solver network that fronts ETH then claims BTC. + // Solvers need 2 BTC confirmations (20-40 min) and must cover ETH gas (~$2-5). + // Amounts below ~$50 USD are systematically refunded — no solver finds it profitable. + if (result.swapper === 'NEAR Intents' && params.fromCaip.startsWith('bip122:')) { + // Block amounts below the protocol's stated minimum + if (result.minAmountIn && parseFloat(params.amount) < parseFloat(result.minAmountIn)) { + throw new Error( + `Amount too small for NEAR Intents — minimum ${result.minAmountIn} BTC required. ` + + `Solvers must front ETH and wait for BTC confirmations; smaller amounts are unprofitable and will be refunded.` + ) + } + // Warn if quote deadline is too short for BTC's 2-confirmation requirement (~20-40 min). + // A 30-min deadline may expire before BTC even confirms, causing automatic refund. + const nowSec = Math.floor(Date.now() / 1000) + const minutesUntilExpiry = result.expiry ? (result.expiry - nowSec) / 60 : 0 + if (result.expiry && minutesUntilExpiry < 60) { + console.warn( + `${TAG} NEAR Intents BTC→EVM quote expires in ${minutesUntilExpiry.toFixed(0)} min — ` + + `BTC requires 2 confirmations (20-40 min). If BTC doesn't confirm in time, swap will be refunded.` + ) + } + } - 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 } @@ -206,6 +324,19 @@ export interface SwapWallet { } /** Dependencies injected by the caller (index.ts) to avoid circular imports */ +/** Substage of executeSwap. The frontend's coarse phase ('approving' / + * 'signing' / 'broadcasting') stays the same; this finer-grained value is a + * separate UI signal so the "Approving token… 1/2" label can become + * "Broadcasting approval…" / "Sign swap on device" etc. as the flow + * progresses. Without this, ERC-20 swaps display "Approving token… 1/2" + * for the entire executeSwap including the swap signing step (retro #1). */ +export type SwapSubStage = + | 'approve-signing' // device prompting for ERC-20 approve + | 'approve-broadcasting' // approve tx going to mempool + | 'approve-waiting-receipt' // waiting for approve to confirm + | 'swap-signing' // device prompting for the swap itself + | 'swap-broadcasting' // swap tx going to mempool + export interface SwapContext { wallet: SwapWallet getAllChains: () => ChainDef[] @@ -214,19 +345,27 @@ export interface SwapContext { getAllBtcXpubs: () => Array<{ xpub: string; scriptType: string; accountPath: number[] }> // all funded BTC xpubs /** Wrap signing ops for emulator (shows confirm UI). Pass-through on real device. */ wrapSign: (fn: () => Promise, details: { operation: string; chain?: string; to?: string; value?: string; memo?: string }) => Promise + /** Push a finer-grained substage label to the UI. Required (use NOOP_PUSH_SUBSTAGE + * for REST/headless callers) so a future entry point can't silently regress + * the UI to a coarse phase by forgetting to wire it up. */ + pushSubStage: (stage: SwapSubStage) => void } +/** Sentinel no-op for SwapContext.pushSubStage in REST/headless paths. */ +export const NOOP_PUSH_SUBSTAGE = (_stage: SwapSubStage): void => { /* intentional no-op */ } + /** Execute a swap: build tx, sign on device, broadcast */ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): Promise { - const { wallet, getAllChains, getRpcUrl, getBtcXpub, getAllBtcXpubs, wrapSign } = ctx + const { wallet, getAllChains, getRpcUrl, getBtcXpub, getAllBtcXpubs, wrapSign, pushSubStage } = ctx + const stage = (s: SwapSubStage) => { try { pushSubStage(s) } catch { /* never block on push */ } } // 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' + // CAIP-driven: '/erc20:' / '/bep20:' namespaces are tokens; '/slip44:' is native. + const isErc20Source = isTokenCaip(params.fromCaip) && fromChain.chainFamily === 'evm' // 1. Get sender address (use override if provided, otherwise derive from defaultPath) let fromAddress = params.fromAddressOverride @@ -276,12 +415,20 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): } // 2. Validate required fields - // Relay integration provides pre-built tx with calldata — no memo or inbound address needed - const isRelay = params.integration === 'relay' && !!params.relayTx + // Calldata-based integrations (relay, shapeshiftSwap, …) ship the full + // tx in `relayTx` — no memo or inbound address needed. Anything else is + // memo+vault-routed (THORChain/Maya). + const hasPrebuiltTx = !!params.relayTx // Native THORChain/Maya deposits (RUNE, CACAO) use MsgDeposit — no inbound vault needed - const isNativeDeposit = params.fromAsset === 'THOR.RUNE' || params.fromAsset === 'MAYA.CACAO' - if (!params.inboundAddress && !isNativeDeposit && !isRelay) throw new Error('Missing inbound vault address from quote') - if (!params.memo && !isRelay) throw new Error('Missing swap memo from quote') + const isNativeDeposit = isNativeDepositCaip(params.fromCaip) + // NEAR Intents (memo-less UTXO → EVM): deposit address is the only instruction. + // Detected by fromCaip chain family — `swapper` is not in ExecuteSwapParams. + // Direction guard: only valid for UTXO sources; EVM → BTC with no calldata + // has no way to encode the BTC destination. + const fromIsUtxo = params.fromCaip.startsWith('bip122:') + const isMemolessTransfer = fromIsUtxo && !!params.inboundAddress && !params.memo + if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error('Missing inbound vault address from quote') + if (!params.memo && !hasPrebuiltTx && !isMemolessTransfer) throw new Error('Missing swap memo from quote') if (params.memo) { const memoByteLength = Buffer.byteLength(params.memo, 'utf8') if (memoByteLength > MEMO_LIMIT) { @@ -289,13 +436,13 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): } } - console.log(`${TAG} Executing: ${params.fromAsset} → ${params.toAsset}, amount=${params.amount}`) - if (isRelay) { - console.log(`${TAG} Relay integration — using pre-built tx (to=${params.relayTx!.to}, chainId=${params.relayTx!.chainId})`) + swapLog(`${TAG} Executing: ${params.fromCaip} → ${params.toCaip}, amount=${params.amount}`) + if (hasPrebuiltTx) { + swapLog(`${TAG} ${params.integration} — using pre-built tx (to=${params.relayTx!.to}, chainId=${params.relayTx!.chainId})`) } else { - console.log(`${TAG} Chain family: ${fromChain.chainFamily}, vault: ${params.inboundAddress || 'MsgDeposit'}, router: ${params.router || 'none'}`) + swapLog(`${TAG} Chain family: ${fromChain.chainFamily}, vault: ${params.inboundAddress || 'MsgDeposit'}, router: ${params.router || 'none'}`) } - if (isErc20Source) console.log(`${TAG} ERC-20 source detected: ${params.fromAsset}`) + if (isErc20Source) swapLog(`${TAG} ERC-20 source detected: ${params.fromCaip}`) // 3. Get Pioneer for tx building const pioneer = await getPioneer() @@ -303,14 +450,45 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): let unsignedTx: any let approvalTxid: string | undefined - // ── Relay integration: sign pre-built tx directly ── - if (isRelay) { - const result = await buildRelaySwapTx(params, fromChain, fromAddress, getRpcUrl) + // ── Calldata integrations (relay, shapeshiftSwap, …): sign prebuilt tx ── + if (hasPrebuiltTx) { + const result = await buildRelaySwapTx(params, fromChain, fromAddress, getRpcUrl, isErc20Source, /* previewMode */ false) unsignedTx = result.unsignedTx + // ERC-20 relay txs may need an approval to the router (THORChain Router, + // 0x exchange proxy, etc.) — without it the router's transferFrom call + // reverts on-chain. Same pattern as buildEvmSwapTx but driven from here. + if (result.approveTx) { + swapLog(`${TAG} Relay ERC-20 approval required: prompting device for approveTx`) + stage('approve-signing') + const signedApprove = await wallet.ethSignTx(result.approveTx) + swapLog(`${TAG} Device signed approveTx`) + let approveHex: string = typeof signedApprove === 'string' + ? signedApprove + : (signedApprove?.serializedTx || signedApprove?.serialized || '') + if (!approveHex) throw new Error('Failed to extract serialized approve tx (relay path)') + if (!approveHex.startsWith('0x')) approveHex = '0x' + approveHex + const rpcUrl = getRpcUrl(fromChain) + stage('approve-broadcasting') + if (rpcUrl) { + approvalTxid = await broadcastEvmTx(rpcUrl, approveHex) + swapLog(`${TAG} Relay-path approve broadcast: ${approvalTxid}`) + stage('approve-waiting-receipt') + const receipt = await waitForTxReceipt(rpcUrl, approvalTxid, 180_000) + if (receipt && !receipt.status) { + throw new Error(`Relay-path ERC-20 approve reverted (txid: ${approvalTxid}). Swap aborted — no relay tx sent.`) + } + if (!receipt) console.warn(`${TAG} Relay-path approve receipt not confirmed in 180s — proceeding (nonce gap risk)`) + } else { + const approveResult = await pioneer.Broadcast({ networkId: fromChain.networkId, serialized: approveHex }) + approvalTxid = approveResult?.data?.txid || approveResult?.data?.tx_hash || approveResult?.data?.hash + swapLog(`${TAG} Relay-path approve broadcast (Pioneer): ${approvalTxid}`) + } + } + // ── EVM chains: MUST use router contract depositWithExpiry() ── } else if (fromChain.chainFamily === 'evm') { - const result = await buildEvmSwapTx(params, fromChain, fromAddress, pioneer, getRpcUrl, isErc20Source, wallet) + const result = await buildEvmSwapTx(params, fromChain, fromAddress, pioneer, getRpcUrl, isErc20Source, wallet, /* previewMode */ false, stage) unsignedTx = result.unsignedTx approvalTxid = result.approvalTxid @@ -320,12 +498,12 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): let accountPath: number[] | undefined let allXpubs: Array<{ xpub: string; scriptType: string; accountPath: number[] }> | undefined - if (fromChain.id === 'bitcoin') { + if (isBitcoin(fromChain)) { // BTC: aggregate UTXOs from ALL funded xpubs (p2pkh + p2sh-p2wpkh + p2wpkh) try { allXpubs = getAllBtcXpubs() if (allXpubs.length > 0) { - console.log(`${TAG} BTC multi-xpub: ${allXpubs.length} funded xpubs`) + swapLog(`${TAG} BTC multi-xpub: ${allXpubs.length} funded xpubs`) // Primary xpub for change address = selected, or first funded const btcInfo = getBtcXpub() xpub = btcInfo?.xpub || allXpubs[0].xpub @@ -339,6 +517,32 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): if (btcInfo) { xpub = btcInfo.xpub; accountPath = btcInfo.accountPath } } catch {} } + if (!xpub) { + // Lazy-init: btcAccountManager hasn't been hydrated (user opened swap + // without visiting BTC dashboard first). Derive ALL three scriptTypes + // (Legacy/SegWit/NativeSegWit) from device — funds may live on any. + // Without this we'd fall through to the chain.scriptType fallback below + // which is `p2pkh` (Legacy) and miss every modern wallet. + const paths = BTC_SCRIPT_TYPES.map(st => ({ + addressNList: btcAccountPath(st.purpose, 0), + coin: 'Bitcoin', + scriptType: st.scriptType, + curve: 'secp256k1', + })) + const results = await wallet.getPublicKeys(paths) + const derived: Array<{ xpub: string; scriptType: string; accountPath: number[] }> = [] + for (let i = 0; i < BTC_SCRIPT_TYPES.length; i++) { + const xp = results?.[i]?.xpub + if (xp) derived.push({ xpub: xp, scriptType: BTC_SCRIPT_TYPES[i].scriptType, accountPath: paths[i].addressNList }) + } + if (derived.length > 0) { + const native = derived.find(d => d.scriptType === 'p2wpkh') || derived[0] + xpub = native.xpub + accountPath = native.accountPath + allXpubs = derived + swapLog(`${TAG} BTC lazy-derive: ${derived.length} scriptTypes from device (primary=${native.scriptType})`) + } + } } if (!xpub) { try { @@ -370,6 +574,22 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // ── All other chains (Cosmos, XRP, Solana, Tron, TON): send to vault with memo ── } else { + // Resolve the canonical CAIP for the source asset and pull token decimals + // from the cached SwapAsset list. Without this, buildTx's TRC-20 detection + // (which keys off `params.caip` matching `tron:.../token:T...`) never + // triggers for a USDT-on-TRON source — buildTx falls through to the native + // TRX `createtransaction` path and would send `params.amount` as TRX to the + // THORChain inbound address instead of as a USDT.transfer() call. Same + // pattern would matter for any future SPL/non-EVM token sends. + const knownAssets = await getSwapAssets() + // CAIP-keyed lookup. Synthesized assets (picker selections Pioneer didn't + // pre-list) won't be in knownAssets; fromAssetMeta is undefined in that + // case and downstream code already handles it (decimals fall back to + // chain default; sourceCaip falls back to params.fromCaip). + const fromAssetMeta = knownAssets.find(a => a.caip === params.fromCaip) + const sourceCaip = fromAssetMeta?.caip ?? params.fromCaip + const tokenDecimals = fromAssetMeta?.decimals + const buildResult = await txb.buildTx(pioneer, fromChain, { chainId: fromChain.id, // MsgDeposit (RUNE/CACAO native swaps) ignores `to` — use sender as fallback @@ -380,12 +600,15 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): isMax: params.isMax, isSwapDeposit: true, // C1 fix: explicit flag for MsgDeposit (not inferred from memo) fromAddress, + caip: sourceCaip, + tokenDecimals, }) unsignedTx = buildResult.unsignedTx } // 4. Sign on device (user confirms tx details on hardware wallet) - console.log(`${TAG} Signing ${fromChain.chainFamily} tx via ${fromChain.signMethod}...`) + swapLog(`${TAG} Signing ${fromChain.chainFamily} tx via ${fromChain.signMethod}...`) + stage('swap-signing') let signedTx: any try { signedTx = await wrapSign( @@ -398,9 +621,10 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): console.error(`${TAG} stack: ${e.stack?.split('\n').slice(0, 5).join('\n')}`) throw e } - console.log(`${TAG} Sign complete, serialized=${!!signedTx?.serialized || !!signedTx?.serializedTx}`) + swapLog(`${TAG} Sign complete, serialized=${!!signedTx?.serialized || !!signedTx?.serializedTx}`) // 5. Broadcast — prefer direct RPC for EVM chains (Pioneer relay can silently drop txs) + stage('swap-broadcasting') let txid: string const swapRpcUrl = fromChain.chainFamily === 'evm' ? getRpcUrl(fromChain) : undefined if (swapRpcUrl && fromChain.chainFamily === 'evm') { @@ -412,8 +636,15 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): } try { txid = await broadcastEvmTx(swapRpcUrl, serializedHex) - console.log(`${TAG} Broadcast via direct RPC: ${txid}`) + swapLog(`${TAG} Broadcast via direct RPC: ${txid}`) } catch (directErr: any) { + // Insufficient funds: Pioneer fallback will also fail — surface immediately + if (directErr.message?.toLowerCase().includes('insufficient funds')) { + throw new Error( + `Insufficient ${fromChain.symbol} for gas on ${fromChain.id}. ` + + `Add ${fromChain.symbol} to your wallet to pay for transaction fees and try again.` + ) + } console.warn(`${TAG} Direct RPC broadcast failed (${directErr.message}), falling back to Pioneer...`) try { const result = await txb.broadcastTx(pioneer, fromChain, signedTx) @@ -436,12 +667,12 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): } } - console.log(`${TAG} Broadcast success: ${txid}`) + swapLog(`${TAG} Broadcast success: ${txid}`) return { txid, - fromAsset: params.fromAsset, - toAsset: params.toAsset, + fromCaip: params.fromCaip, + toCaip: params.toCaip, fromAmount: params.amount, expectedOutput: params.expectedOutput, ...(approvalTxid ? { approvalTxid } : {}), @@ -450,20 +681,153 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // ── Relay swap tx building (pre-built calldata from bridge protocol) ── +/** Build the unsigned swap tx(s) without signing or broadcasting. Used by + * the UI to surface the exact hdwallet payload on the Confirm Quote screen + * so the user can audit before clicking Confirm. For ERC-20 sources that + * need approval, returns BOTH the approve tx and the projected deposit tx + * (with nonce assumed to advance after approval). */ +export async function previewSwapBuild( + params: ExecuteSwapParams, + ctx: SwapContext, +): Promise<{ approveTx?: any; unsignedTx: any; allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string }; balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } }> { + const { wallet, getAllChains, getRpcUrl, getBtcXpub, getAllBtcXpubs } = ctx + + const allChains = getAllChains() + const fromChain = allChains.find(c => c.id === params.fromChainId) + if (!fromChain) throw new Error(`Unknown source chain: ${params.fromChainId}`) + const toChain = allChains.find(c => c.id === params.toChainId) + if (!toChain) throw new Error(`Unknown destination chain: ${params.toChainId}`) + + const isErc20Source = isTokenCaip(params.fromCaip) && fromChain.chainFamily === 'evm' + + let fromAddress = params.fromAddressOverride + if (!fromAddress) { + 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) + fromAddress = typeof addrResult === 'string' ? addrResult : addrResult?.address + } + if (!fromAddress) throw new Error('Could not derive sender address') + + const hasPrebuiltTx = !!params.relayTx + const isNativeDeposit = isNativeDepositCaip(params.fromCaip) + // NEAR Intents (memo-less UTXO → EVM): CAIP-based detection (swapper not in ExecuteSwapParams) + const fromIsUtxoPreview = params.fromCaip.startsWith('bip122:') + const isMemolessTransfer = fromIsUtxoPreview && !!params.inboundAddress && !params.memo + if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error('Missing inbound vault address from quote') + if (!params.memo && !hasPrebuiltTx && !isMemolessTransfer) throw new Error('Missing swap memo from quote') + + const pioneer = await getPioneer() + + if (hasPrebuiltTx) { + const result = await buildRelaySwapTx(params, fromChain, fromAddress, getRpcUrl, isErc20Source, /* previewMode */ true) + return { unsignedTx: result.unsignedTx, approveTx: result.approveTx, allowance: result.allowance, balance: result.balance } + } + if (fromChain.chainFamily === 'evm') { + const result = await buildEvmSwapTx(params, fromChain, fromAddress, pioneer, getRpcUrl, isErc20Source, wallet, /* previewMode */ true) + return { unsignedTx: result.unsignedTx, approveTx: result.approveTx, allowance: result.allowance, balance: result.balance } + } + if (fromChain.chainFamily === 'utxo') { + let xpub: string | undefined + let accountPath: number[] | undefined + let allXpubs: Array<{ xpub: string; scriptType: string; accountPath: number[] }> | undefined + if (isBitcoin(fromChain)) { + try { + allXpubs = getAllBtcXpubs() + if (allXpubs.length > 0) { + const btcInfo = getBtcXpub() + xpub = btcInfo?.xpub || allXpubs[0].xpub + accountPath = btcInfo?.accountPath || allXpubs[0].accountPath + } + } catch { /* BTC account manager not ready */ } + // The cached btc-account-manager fallback ONLY applies to BTC. Without + // this gate the preview path picked up the user's BTC zpub + // (m/84'/0'/0', p2wpkh) and queried Pioneer for ZEC unspents under the + // Bitcoin native-segwit account — Pioneer naturally returned 0 UTXOs. + // Symptom: "Build preview failed: No UTXOs found for Zcash" while + // standalone ZEC sends worked because they take a different code path. + if (!xpub) { + const btcInfo = (() => { try { return getBtcXpub() } catch { return undefined } })() + if (btcInfo) { xpub = btcInfo.xpub; accountPath = btcInfo.accountPath } + } + } + if (!xpub && isBitcoin(fromChain)) { + // Lazy-init: same as executeSwap — derive all 3 BTC scriptTypes when + // btcAccountManager is empty. See executeSwap path for rationale. + const paths = BTC_SCRIPT_TYPES.map(st => ({ + addressNList: btcAccountPath(st.purpose, 0), + coin: 'Bitcoin', + scriptType: st.scriptType, + curve: 'secp256k1', + })) + const results = await wallet.getPublicKeys(paths) + const derived: Array<{ xpub: string; scriptType: string; accountPath: number[] }> = [] + for (let i = 0; i < BTC_SCRIPT_TYPES.length; i++) { + const xp = results?.[i]?.xpub + if (xp) derived.push({ xpub: xp, scriptType: BTC_SCRIPT_TYPES[i].scriptType, accountPath: paths[i].addressNList }) + } + if (derived.length > 0) { + const native = derived.find(d => d.scriptType === 'p2wpkh') || derived[0] + xpub = native.xpub + accountPath = native.accountPath + allXpubs = derived + } + } + if (!xpub) { + const result = await wallet.getPublicKeys([{ + addressNList: fromChain.defaultPath.slice(0, 3), + coin: fromChain.coin, + scriptType: fromChain.scriptType, + curve: 'secp256k1', + }]) + xpub = result?.[0]?.xpub + } + 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, allXpubs, accountPath, + }) + return { unsignedTx: buildResult.unsignedTx } + } + // cosmos / xrp / solana / tron / ton + const knownAssets = await getSwapAssets() + const fromAssetMeta = knownAssets.find(a => a.caip === params.fromCaip) + const buildResult = await txb.buildTx(pioneer, fromChain, { + chainId: fromChain.id, + to: params.inboundAddress || fromAddress, + amount: params.amount, memo: params.memo, feeLevel: params.feeLevel, isMax: params.isMax, + isSwapDeposit: true, fromAddress, + // Prefer Pioneer's canonical CAIP (correct case for TRON tokens) but fall + // back to the picker-supplied CAIP for synthesized selections. + caip: fromAssetMeta?.caip ?? params.fromCaip, tokenDecimals: fromAssetMeta?.decimals, + }) + return { unsignedTx: buildResult.unsignedTx } +} + async function buildRelaySwapTx( params: ExecuteSwapParams, fromChain: ChainDef, fromAddress: string, getRpcUrl: (chain: ChainDef) => string | undefined, -): Promise<{ unsignedTx: any }> { + isErc20Source = false, + _previewMode = false, // reserved — caller (executeSwap vs previewSwapBuild) handles signing/broadcasting +): Promise<{ unsignedTx: any; approveTx?: any; allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string }; balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } }> { const relay = params.relayTx! + console.log(`${TAG} buildRelaySwapTx: relay.value=${relay.value} relay.gasLimit=${relay.gasLimit} relay.maxFeePerGas=${relay.maxFeePerGas} relay.maxPriorityFeePerGas=${relay.maxPriorityFeePerGas}`) - // Guard: relay chainId MUST match the locally-resolved source chain to prevent - // signing a tx for chain A with a nonce fetched from chain B. + // Guard: when the quote ships a chainId, it MUST match the locally-resolved + // source chain to prevent signing a tx for chain A with a nonce from chain + // B (real risk for cross-chain bridges like Relay). Single-chain + // aggregators (e.g. shapeshiftSwap on ETH → ETH) omit chainId because + // it's implicit; in that case we trust the source chain. const expectedChainId = parseInt(fromChain.chainId || '0', 10) - if (relay.chainId !== expectedChainId) { + if (relay.chainId != null && relay.chainId !== expectedChainId) { throw new Error( - `Relay chainId mismatch: quote says ${relay.chainId} but source chain ${fromChain.id} is ${expectedChainId}. ` + + `Quote chainId mismatch: quote says ${relay.chainId} but source chain ${fromChain.id} is ${expectedChainId}. ` + `Aborting — stale or mismatched quote.` ) } @@ -493,7 +857,22 @@ async function buildRelaySwapTx( // Use gas params from relay quote, with sane fallbacks. // Mirror the normal EVM path: try quote fields → RPC → Pioneer → chain-specific floor. - const gasLimit = relay.gasLimit || '300000' + // + // Relay gas limit: use quote-provided value, then chain-specific fallback. + // 300000 is correct only for Arbitrum (different gas accounting). L2s like + // Base/Optimism use mainnet-equivalent gas units; 300000 overestimates by 3-6x + // and causes the pre-sign balance check to fail on tight balances. + const RELAY_GAS_LIMIT_FALLBACK: Record = { + arbitrum: '300000', + base: '100000', + optimism: '100000', + polygon: '150000', + avalanche: '150000', + bsc: '150000', + ethereum: '150000', + } + const gasLimit = relay.gasLimit || RELAY_GAS_LIMIT_FALLBACK[fromChain.id] || '150000' + console.log(`${TAG} relay gasLimit: provided=${relay.gasLimit} resolved=${gasLimit} chain=${fromChain.id}`) const fallbackGwei = MIN_GAS_GWEI[fromChain.id] ?? 10 const fallbackGasPrice = BigInt(Math.round(fallbackGwei * 1e9)) let gasPrice: string | undefined @@ -501,32 +880,208 @@ async function buildRelaySwapTx( let maxPriorityFeePerGas: string | undefined if (relay.maxFeePerGas) { - // EIP-1559 tx — use quote values - maxFeePerGas = relay.maxFeePerGas - maxPriorityFeePerGas = relay.maxPriorityFeePerGas || '1000000' // 0.001 gwei fallback - } else { - // Legacy gas price — try RPC, then Pioneer, then chain-specific floor + // EIP-1559 tx — start from Relay's quote, but cross-check against our own + // locally-computed buffer (nextBaseFee * 3 + 1.5 gwei priority floor). Relay + // can ship a maxFeePerGas that was current at quote time but stale by + // broadcast; if local says higher, we use local so the tx isn't stranded + // when base fee spikes between quote and signing. + const relayMaxFee = BigInt(relay.maxFeePerGas) + const relayPrio = relay.maxPriorityFeePerGas + ? BigInt(relay.maxPriorityFeePerGas) + : BigInt(1_500_000_000) // 1.5 gwei — typical ETH-mainnet inclusion tip + let chosenMaxFee = relayMaxFee + let chosenPrio = relayPrio if (rpcUrl) { + const liveFee = await getEvmFeeData(rpcUrl).catch(() => null) + if (liveFee) { + if (liveFee.maxFeePerGas > chosenMaxFee) { + swapLog(`${TAG} Relay tx: bumping maxFeePerGas ${chosenMaxFee} → ${liveFee.maxFeePerGas} (local 3x buffer beats Relay quote)`) + chosenMaxFee = liveFee.maxFeePerGas + } + if (liveFee.maxPriorityFeePerGas > chosenPrio) chosenPrio = liveFee.maxPriorityFeePerGas + } + } + if (chosenPrio > chosenMaxFee) { + swapLog(`${TAG} Relay tx: bumping maxFeePerGas ${chosenMaxFee} → ${chosenPrio} to cover maxPriorityFeePerGas`) + chosenMaxFee = chosenPrio + } + maxFeePerGas = toHex(chosenMaxFee) + maxPriorityFeePerGas = toHex(chosenPrio) + } else if (rpcUrl) { + // Quote shipped only legacy gasPrice (or nothing) — prefer EIP-1559 from RPC + // since legacy gasPrice on EIP-1559 chains often comes back below base fee. + const feeData = await getEvmFeeData(rpcUrl) + if (feeData) { + // Floor maxFeePerGas at 2x chain min (so we still beat base on quiet chains) + const floor1559 = fallbackGasPrice * 2n + maxFeePerGas = toHex(feeData.maxFeePerGas > floor1559 ? feeData.maxFeePerGas : floor1559) + maxPriorityFeePerGas = toHex(feeData.maxPriorityFeePerGas) + swapLog(`${TAG} Relay tx: using EIP-1559 from RPC (maxFee=${feeData.maxFeePerGas}, prio=${feeData.maxPriorityFeePerGas})`) + } else { + // Chain doesn't support eth_feeHistory — fall back to legacy gasPrice with floor try { const gp = await getEvmGasPrice(rpcUrl) gasPrice = toHex(gp < fallbackGasPrice ? fallbackGasPrice : gp) } catch { - console.warn(`${TAG} RPC gas price failed for relay tx, trying Pioneer...`) + gasPrice = toHex(fallbackGasPrice) } } - if (!gasPrice) { + } + if (!maxFeePerGas && !gasPrice) { + // No RPC available — last-resort Pioneer + floor + try { + const pioneer = await getPioneer() + 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)) + const gpWei = BigInt(Math.round((isNaN(gpGwei) ? fallbackGwei : gpGwei) * 1e9)) + gasPrice = toHex(gpWei < fallbackGasPrice ? fallbackGasPrice : gpWei) + } catch (e: any) { + console.warn(`${TAG} Pioneer gas price failed for relay tx, using ${fallbackGwei} gwei floor: ${e.message}`) + gasPrice = toHex(fallbackGasPrice) + } + } + + const relayValue = BigInt(relay.value) + const relayGasLimit = BigInt(gasLimit) + const relayFeePerGas = maxFeePerGas || gasPrice + if (!relayFeePerGas) { + throw new Error(`Unable to determine gas fee for Relay transaction on ${fromChain.id} — refusing to sign. Try refreshing the quote.`) + } + // EIP-1559 nodes require the sender to cover gasLimit * maxFeePerGas + value before + // broadcasting. The balance check and the fee cap placed in the signed tx MUST agree, + // otherwise a tight balance passes the check but the broadcast rejects. + // + // Strategy: take the relay's own quoted maxFeePerGas as the signed cap. The live bump + // computed above is discarded — the relay quote already reflects current network conditions + // at quote time, and on L2s (Base, Optimism) the 3× live-buffer produces wildly over- + // estimated caps that block valid swaps on tight balances. If the relay's cap proves + // insufficient for inclusion, the tx will be mined anyway at priority-fee level once + // the base fee drops — or the user can refresh the quote to get a fresh cap. + const signedFeePerGas: bigint = relay.maxFeePerGas + ? BigInt(relay.maxFeePerGas) + : BigInt(relayFeePerGas) // no quoted fee → fall back to live (gasPrice path) + const signedPrioFeePerGas: bigint = relay.maxPriorityFeePerGas + ? BigInt(relay.maxPriorityFeePerGas) + : signedFeePerGas // no quoted prio → cap at max + const relayGasReserve = relayGasLimit * signedFeePerGas + // ERC-20 relay swaps run an approve tx before the swap. Reserve gas for both. + const approveGasReserve = isErc20Source ? 80000n * signedFeePerGas : 0n + const relayNativeRequired = relayValue + relayGasReserve + approveGasReserve + console.log(`${TAG} relay fee: quoted=${relay.maxFeePerGas} liveBumped=${relayFeePerGas} signedCap=${signedFeePerGas}`) + let nativeBalance: bigint | undefined + if (rpcUrl) { + try { + nativeBalance = await getEvmBalance(rpcUrl, fromAddress) + } catch (e: any) { + console.warn(`${TAG} Failed to fetch native balance via RPC for relay tx: ${e.message}`) + } + } + if (nativeBalance === undefined) { + try { + const pioneer = await getPioneer() + const bd = await pioneer.GetBalanceAddressByNetwork({ networkId: fromChain.networkId, address: fromAddress }) + const balStr = String(bd?.data?.nativeBalance || bd?.data?.balance || '0') + nativeBalance = parseUnits(balStr, fromChain.decimals) + } catch (e: any) { + console.warn(`${TAG} Failed to fetch native balance via Pioneer for relay tx: ${e.message}`) + } + } + if (nativeBalance === undefined) { + throw new Error(`Unable to verify ${fromChain.symbol} balance for Relay transaction — refusing to sign. Try refreshing the quote.`) + } + console.log(`${TAG} relay gas check: value=${formatWei(relayValue, fromChain.decimals)} gasReserve=${formatWei(relayGasReserve, fromChain.decimals)} required=${formatWei(relayNativeRequired, fromChain.decimals)} balance=${formatWei(nativeBalance, fromChain.decimals)} ${fromChain.symbol}`) + if (nativeBalance < relayNativeRequired) { + if (params.isMax && !isErc20Source) { + throw new Error( + `Relay quote is stale: updated gas fees require ${formatWei(relayNativeRequired, fromChain.decimals)} ${fromChain.symbol} ` + + `but the wallet has ${formatWei(nativeBalance, fromChain.decimals)}. Refresh the quote so the max send amount can reserve gas before signing.` + ) + } + throw new Error( + `Insufficient ${fromChain.symbol} for Relay transaction: need ${formatWei(relayNativeRequired, fromChain.decimals)} ` + + `(${formatWei(relayValue, fromChain.decimals)} value + ${formatWei(relayGasReserve, fromChain.decimals)} gas), ` + + `have ${formatWei(nativeBalance, fromChain.decimals)}.` + ) + } + + // ── ERC-20 allowance check & approve generation ──────────────────── + // Relay-aggregator txs (e.g. shapeshiftSwap calling THORChain Router) pull + // ERC-20 tokens via transferFrom — the user MUST have approved relay.to as + // the spender. Without this check the swap silently reverts on-chain. + let pendingApproveTx: any | undefined + let allowanceInfo: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string } | undefined + let balanceInfo: { current: string; required: string; sufficient: boolean; tokenContract?: string } | undefined + + if (isErc20Source && rpcUrl) { + // Token contract comes from the CAIP-19 (after the `:` of `/erc20:` or + // `/bep20:`). CAIP is the only identifier — no separate contract param. + const tokenContract = (extractContractFromCaip(params.fromCaip) || '').toLowerCase() + if (tokenContract && tokenContract.startsWith('0x')) { try { - const pioneer = await getPioneer() - 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)) - const gpWei = BigInt(Math.round((isNaN(gpGwei) ? fallbackGwei : gpGwei) * 1e9)) - gasPrice = toHex(gpWei < fallbackGasPrice ? fallbackGasPrice : gpWei) + const tokenDecimals = await getErc20Decimals(rpcUrl, tokenContract).catch(() => undefined) + if (tokenDecimals !== undefined) { + const amountBaseUnits = parseUnits(params.amount, tokenDecimals) + const [currentAllowance, currentBalance] = await Promise.all([ + getErc20Allowance(rpcUrl, tokenContract, fromAddress, relay.to).catch(() => 0n), + getErc20Balance(rpcUrl, tokenContract, fromAddress).catch(() => null), + ]) + const sufficient = currentAllowance >= amountBaseUnits + + allowanceInfo = { + current: currentAllowance.toString(), + required: amountBaseUnits.toString(), + sufficient, + spender: relay.to, + tokenContract, + } + if (currentBalance !== null) { + const balSufficient = currentBalance >= amountBaseUnits + balanceInfo = { + current: currentBalance.toString(), + required: amountBaseUnits.toString(), + sufficient: balSufficient, + tokenContract, + } + swapLog(`${TAG} Relay tx balance check: current=${currentBalance}, required=${amountBaseUnits}, sufficient=${balSufficient}`) + if (!balSufficient && !_previewMode) { + throw new Error(`Insufficient ${tokenContract} balance: have ${currentBalance.toString()} units, need ${amountBaseUnits.toString()} units. The swap would revert on-chain — refusing to sign.`) + } + } + swapLog(`${TAG} Relay tx allowance check: current=${currentAllowance}, required=${amountBaseUnits}, sufficient=${sufficient}`) + + if (!sufficient) { + // Build approveTx — same pattern as buildEvmSwapTx (exact-amount, + // for hardware-wallet safety; not MaxUint256). + const approveData = encodeApprove(relay.to, amountBaseUnits) + const approveGasLimit = 80000n + const approveTx: any = { + chainId, + addressNList: fromChain.defaultPath, + nonce: toHex(BigInt(nonce)), + gasLimit: toHex(approveGasLimit), + to: tokenContract, + value: '0x0', + data: approveData, + } + if (maxFeePerGas) { + // Use signedFeePerGas (relay's quoted cap) to match the balance check and the swap tx. + approveTx.maxFeePerGas = toHex(signedFeePerGas) + approveTx.maxPriorityFeePerGas = toHex(signedPrioFeePerGas) + } else if (gasPrice) { + approveTx.gasPrice = gasPrice + } + pendingApproveTx = approveTx + // Caller (executeSwap) handles the gate + sign + broadcast + receipt + // wait for the approve. We just project the nonce here so the + // returned relay tx uses the post-approval nonce. + nonce += 1 + } + } } catch (e: any) { - console.warn(`${TAG} Pioneer gas price failed for relay tx, using ${fallbackGwei} gwei floor: ${e.message}`) - gasPrice = toHex(fallbackGasPrice) + console.warn(`${TAG} Relay allowance check failed (non-fatal): ${e?.message}`) } } } @@ -538,20 +1093,58 @@ async function buildRelaySwapTx( nonce: toHex(BigInt(nonce)), gasLimit: toHex(BigInt(gasLimit)), to: relay.to, - value: toHex(BigInt(relay.value)), + value: toHex(relayValue), data: relay.data, } - // EIP-1559 fields - if (maxFeePerGas) { - unsignedTx.maxFeePerGas = toHex(BigInt(maxFeePerGas)) - unsignedTx.maxPriorityFeePerGas = toHex(BigInt(maxPriorityFeePerGas || '1000000')) + // EIP-1559 fields — use signedFeePerGas (relay's quoted cap) so the signed tx + // matches the balance check above. Using the live-bumped cap here while checking + // against the quoted cap would allow a tx that the account cannot cover. + if (relay.maxFeePerGas) { + unsignedTx.maxFeePerGas = toHex(signedFeePerGas) + unsignedTx.maxPriorityFeePerGas = toHex(signedPrioFeePerGas) } else if (gasPrice) { unsignedTx.gasPrice = gasPrice + } else if (maxFeePerGas) { + // No relay-quoted fee — fall back to live-bumped (consistent with signedFeePerGas fallback above) + unsignedTx.maxFeePerGas = maxFeePerGas + unsignedTx.maxPriorityFeePerGas = maxPriorityFeePerGas || toHex(1_000_000n) + } + + // Sanity guard — ERC-20 sources ALWAYS need calldata (transferFrom or + // approveAndDeposit). An empty data field on an ERC-20 relay tx means we'd + // broadcast a plain 0-value transfer with no swap instruction. + // + // Historical incidents: + // - USDT→BTC (Maya, txid 0x8426ca…) — ERC-20 source, dust transfer to non-vault EOA + // + // Cross-chain native-asset swaps (ETH→BTC) via deposit-channel protocols + // (Chainflip, NEAR Intents) legitimately use `data = '0x'` — the swap + // destination was registered off-chain when the quote/channel was created. + // These are flagged `relay.isDepositChannel = true` by parseQuoteResponse + // so we can distinguish them from truly malformed quotes. + const dataIsEmpty = !relay.data || relay.data === '0x' || relay.data === '0x0' || relay.data.length < 10 + if (dataIsEmpty && isErc20Source) { + throw new Error( + `Refusing to sign: source is ERC-20 but relayTx has empty calldata. ` + + `Broadcasting would send a plain transfer to ${relay.to} — no token transfer encoded. ` + + `Pioneer returned a malformed quote — try a different route or pair.` + ) + } + if (dataIsEmpty && !relay.isDepositChannel) { + const isCrossChain = params.fromChainId !== params.toChainId + if (isCrossChain) { + throw new Error( + `Refusing to sign: cross-chain swap (${params.fromChainId} → ${params.toChainId}) ` + + `but relayTx has empty calldata and is not a recognized deposit-channel protocol. ` + + `Broadcasting would send a plain transfer to ${relay.to} with value=${relay.value} ` + + `instead of executing the swap. Pioneer returned a malformed quote — try a different route or pair.` + ) + } } - console.log(`${TAG} Relay tx built: nonce=${nonce}, gasLimit=${gasLimit}, chainId=${chainId}, to=${relay.to}, value=${relay.value}`) - return { unsignedTx } + swapLog(`${TAG} Relay tx built: nonce=${nonce}, gasLimit=${gasLimit}, chainId=${chainId}, to=${relay.to}, value=${relay.value}`) + return { unsignedTx, approveTx: pendingApproveTx, allowance: allowanceInfo, balance: balanceInfo } } // ── EVM swap tx building (extracted for readability) ──────────────── @@ -564,11 +1157,13 @@ async function buildEvmSwapTx( 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 Pioneer inbound_addresses to catch stale/tampered quotes - await validateRouterAddress(params.router, fromChain, pioneer) + previewMode = false, + stage: (s: SwapSubStage) => void = () => {}, +): Promise<{ unsignedTx: any; approvalTxid?: string; approveTx?: any; allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string }; balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } }> { + // Some protocols (e.g. Mayachain) only return `inboundAddress` and use it as the + // router for EVM deposits. Accept either; throw only if both are missing. + const routerAddress = params.router || params.inboundAddress + if (!routerAddress) throw new Error('EVM swaps require a router/inboundAddress from the quote') // Use expiry from quote if available, otherwise 1 hour from now const expiry = params.expiry && params.expiry > Math.floor(Date.now() / 1000) @@ -577,14 +1172,26 @@ async function buildEvmSwapTx( const chainId = parseInt(fromChain.chainId || '1', 10) const rpcUrl = getRpcUrl(fromChain) - // Fetch gas price, nonce, native balance + // Fetch gas price (preferring EIP-1559), nonce, native balance. + // EIP-1559 path: maxFeePerGas + maxPriorityFeePerGas, used on chains that support eth_feeHistory. + // Legacy path: gasPrice, used as fallback. Both paths enforce a chain-specific floor. const fallbackGwei = MIN_GAS_GWEI[fromChain.id] ?? 10 const fallbackGasPrice = BigInt(Math.round(fallbackGwei * 1e9)) - let gasPrice: bigint + let gasPrice: bigint = fallbackGasPrice + let maxFeePerGas: bigint | undefined + let maxPriorityFeePerGas: bigint | undefined + 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 + const feeData = await getEvmFeeData(rpcUrl) + if (feeData) { + const floor1559 = fallbackGasPrice * 2n + maxFeePerGas = feeData.maxFeePerGas > floor1559 ? feeData.maxFeePerGas : floor1559 + maxPriorityFeePerGas = feeData.maxPriorityFeePerGas + swapLog(`${TAG} Using EIP-1559 (maxFee=${maxFeePerGas}, prio=${maxPriorityFeePerGas}) for ${fromChain.id}`) + } else { + 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}`) + } } } else { try { @@ -594,17 +1201,27 @@ async function buildEvmSwapTx( 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 } } - // Enforce minimum gas price floor — RPC/Pioneer frequently report unrealistically low fees - if (gasPrice < fallbackGasPrice) { - console.log(`${TAG} Gas price ${gasPrice} below floor ${fallbackGasPrice} (${fallbackGwei} gwei) — using floor`) + + // Enforce minimum floor on legacy path + if (!maxFeePerGas && gasPrice < fallbackGasPrice) { + swapLog(`${TAG} Gas price ${gasPrice} below floor ${fallbackGasPrice} (${fallbackGwei} gwei) — using floor`) gasPrice = fallbackGasPrice } - if (params.feeLevel != null && params.feeLevel <= 2) gasPrice = gasPrice * 80n / 100n - else if (params.feeLevel != null && params.feeLevel >= 8) gasPrice = gasPrice * 150n / 100n + // Apply user fee level (1-9: 1-2 = slow, 8-9 = fast). Scales whichever fee field is in use. + const feeLevelMul = (n: bigint): bigint => { + if (params.feeLevel != null && params.feeLevel <= 2) return n * 80n / 100n + if (params.feeLevel != null && params.feeLevel >= 8) return n * 150n / 100n + return n + } + if (maxFeePerGas) { + maxFeePerGas = feeLevelMul(maxFeePerGas) + maxPriorityFeePerGas = feeLevelMul(maxPriorityFeePerGas!) + } else { + gasPrice = feeLevelMul(gasPrice) + } let nonce: number | undefined if (rpcUrl) { @@ -644,11 +1261,12 @@ async function buildEvmSwapTx( 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 + // a) Token contract comes from the CAIP-19. CAIP is canonical — no + // separate contract param. Lowercased for internal Map keys (eth_call + // accepts either case). + const tokenContract = (extractContractFromCaip(params.fromCaip) || '').toLowerCase() if (!tokenContract || !tokenContract.startsWith('0x')) { - throw new Error(`Cannot extract token contract from asset: ${params.fromAsset}`) + throw new Error(`Cannot extract token contract from CAIP: ${params.fromCaip}`) } // b) Get token decimals (direct RPC first, then Pioneer fallback) @@ -658,7 +1276,7 @@ async function buildEvmSwapTx( if (rpcUrl) { try { tokenDecimals = await getErc20Decimals(rpcUrl, tokenContract) - console.log(`${TAG} Token decimals (direct RPC): ${tokenDecimals}`) + swapLog(`${TAG} Token decimals (direct RPC): ${tokenDecimals}`) } catch (e: any) { console.warn(`${TAG} Direct RPC decimals failed: ${e.message}, trying Pioneer...`) try { @@ -683,12 +1301,14 @@ async function buildEvmSwapTx( // 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)`) + swapLog(`${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) + // For balance reservation: use the worst-case fee field (maxFeePerGas if EIP-1559) + const effectiveFeePerGas = maxFeePerGas ?? gasPrice + const totalGas = effectiveFeePerGas * (approveGasLimit + depositGasLimit) if (nativeBalance < totalGas) { throw new Error( `Insufficient ${fromChain.symbol} for gas: need ~${formatWei(totalGas)}, ` + @@ -696,35 +1316,72 @@ async function buildEvmSwapTx( ) } - // d) Check allowance + // d) Check allowance + balance (parallel) let needsApproval = true + let currentAllowanceWei = 0n + let currentTokenBalance: bigint | null = null 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}`) + const [allowance, bal] = await Promise.all([ + getErc20Allowance(rpcUrl, tokenContract, fromAddress, routerAddress), + getErc20Balance(rpcUrl, tokenContract, fromAddress).catch(() => null), + ]) + currentAllowanceWei = allowance + currentTokenBalance = bal + needsApproval = currentAllowanceWei < amountBaseUnits + swapLog(`${TAG} Current allowance: ${currentAllowanceWei}, needed: ${amountBaseUnits}, needsApproval: ${needsApproval}`) + if (bal !== null) swapLog(`${TAG} Token balance: ${bal}, needed: ${amountBaseUnits}, sufficient: ${bal >= amountBaseUnits}`) } catch (e: any) { - console.warn(`${TAG} Allowance check failed, assuming approval needed: ${e.message}`) + console.warn(`${TAG} Allowance/balance check failed, assuming approval needed: ${e.message}`) } } + const allowanceInfo = { + current: currentAllowanceWei.toString(), + required: amountBaseUnits.toString(), + sufficient: !needsApproval, + spender: routerAddress, + tokenContract, + } + const balanceInfo = currentTokenBalance !== null ? { + current: currentTokenBalance.toString(), + required: amountBaseUnits.toString(), + sufficient: currentTokenBalance >= amountBaseUnits, + tokenContract, + } : undefined + if (balanceInfo && !balanceInfo.sufficient && !previewMode) { + throw new Error(`Insufficient ${tokenContract} balance: have ${currentTokenBalance!.toString()} units, need ${amountBaseUnits.toString()} units. The swap would revert on-chain — refusing to sign.`) + } // e) If allowance insufficient, sign + broadcast approve tx // H2 fix: approve exact amount (not MaxUint256) — safer for hardware wallet users + let pendingApproveTx: any | undefined if (needsApproval) { - const approveData = encodeApprove(params.router, amountBaseUnits) + const approveData = encodeApprove(routerAddress, amountBaseUnits) - const approveTx = { + const approveTx: any = { 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, } + if (maxFeePerGas) { + approveTx.maxFeePerGas = toHex(maxFeePerGas) + approveTx.maxPriorityFeePerGas = toHex(maxPriorityFeePerGas!) + } else { + approveTx.gasPrice = toHex(gasPrice) + } + pendingApproveTx = approveTx - console.log(`${TAG} Signing ERC-20 approve tx: token=${tokenContract}, spender=${params.router}, amount=${amountBaseUnits}`) + // Preview mode: caller wants the unsigned txs without signing — project + // the deposit nonce as if approval succeeded and skip device sign/broadcast. + if (previewMode) { + nonce += 1 + } else { + swapLog(`${TAG} Signing ERC-20 approve tx: token=${tokenContract}, spender=${routerAddress}, amount=${amountBaseUnits}`) + stage('approve-signing') const signedApprove = await wallet.ethSignTx(approveTx) // Extract serialized tx @@ -742,29 +1399,33 @@ async function buildEvmSwapTx( // Broadcast approve tx if (rpcUrl) { + stage('approve-broadcasting') approvalTxid = await broadcastEvmTx(rpcUrl, approveHex) - console.log(`${TAG} Approve tx broadcast (direct RPC): ${approvalTxid}`) + swapLog(`${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) + // Wait for approval receipt before building deposit — prevents nonce gap if approval reverts. + // 180s tolerates busy mainnet (some hours: pending pool 30-60s, then mining). + swapLog(`${TAG} Waiting for approval receipt (up to 180s)...`) + stage('approve-waiting-receipt') + const receipt = await waitForTxReceipt(rpcUrl, approvalTxid, 180_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)`) + console.warn(`${TAG} Approval receipt not confirmed within 180s — proceeding with deposit (nonce gap risk)`) } else { - console.log(`${TAG} Approval confirmed on-chain (gas used: ${receipt.gasUsed})`) + swapLog(`${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}`) + swapLog(`${TAG} Approve tx broadcast (Pioneer): ${approvalTxid}`) // No receipt check available without RPC — warn user console.warn(`${TAG} No direct RPC — cannot verify approval receipt. Proceeding with deposit.`) } nonce += 1 + } // end !previewMode } // f) Build depositWithExpiry with token contract as asset, value = 0x0 @@ -780,24 +1441,29 @@ async function buildEvmSwapTx( let erc20DepositGas = depositGasLimit if (rpcUrl) { erc20DepositGas = await estimateGas(rpcUrl, { - to: params.router, from: fromAddress, data: depositData, value: '0x0', + to: routerAddress, from: fromAddress, data: depositData, value: '0x0', }, depositGasLimit) - console.log(`${TAG} Estimated deposit gas: ${erc20DepositGas} (fallback: ${depositGasLimit})`) + swapLog(`${TAG} Estimated deposit gas: ${erc20DepositGas} (fallback: ${depositGasLimit})`) } - const unsignedTx = { + const unsignedTx: any = { chainId, addressNList: fromChain.defaultPath, nonce: toHex(nonce), gasLimit: toHex(erc20DepositGas), - gasPrice: toHex(gasPrice), - to: params.router, // ROUTER contract, NOT vault + to: routerAddress, // ROUTER contract, NOT vault value: '0x0', // no ETH value for ERC-20 swaps data: depositData, } + if (maxFeePerGas) { + unsignedTx.maxFeePerGas = toHex(maxFeePerGas) + unsignedTx.maxPriorityFeePerGas = toHex(maxPriorityFeePerGas!) + } else { + unsignedTx.gasPrice = toHex(gasPrice) + } - console.log(`${TAG} ERC-20 router call: to=${params.router}, vault=${params.inboundAddress}, token=${tokenContract}, amount=${amountBaseUnits}`) - return { unsignedTx, approvalTxid } + swapLog(`${TAG} ERC-20 router call: to=${routerAddress}, vault=${params.inboundAddress}, token=${tokenContract}, amount=${amountBaseUnits}`) + return { unsignedTx, approvalTxid, approveTx: pendingApproveTx, allowance: allowanceInfo, balance: balanceInfo } } else { // ── Native asset swap: asset = 0x0, value = amountWei ── @@ -807,12 +1473,14 @@ async function buildEvmSwapTx( // Dynamic gas estimation with static fallback (use static for initial estimate) let gasLimit = staticGasLimit - const gasFee = gasPrice * gasLimit + // Reservation uses worst-case fee per gas (maxFeePerGas if EIP-1559, else gasPrice) + const reservedGasPrice = maxFeePerGas ?? gasPrice + const gasFee = reservedGasPrice * gasLimit // sendMax: deduct gas from send amount so the entire balance is used if (params.isMax && nativeBalance > gasFee) { amountWei = nativeBalance - gasFee - console.log(`${TAG} sendMax: adjusted amount to ${formatWei(amountWei)} ${fromChain.symbol} (balance ${formatWei(nativeBalance)} - gas ${formatWei(gasFee)})`) + swapLog(`${TAG} sendMax: adjusted amount to ${formatWei(amountWei)} ${fromChain.symbol} (balance ${formatWei(nativeBalance)} - gas ${formatWei(gasFee)})`) } const data = encodeDepositWithExpiry( @@ -827,16 +1495,16 @@ async function buildEvmSwapTx( if (rpcUrl) { try { gasLimit = await estimateGas(rpcUrl, { - to: params.router, from: fromAddress, data, value: toHex(amountWei), + to: routerAddress, from: fromAddress, data, value: toHex(amountWei), }, staticGasLimit) - console.log(`${TAG} Estimated native deposit gas: ${gasLimit} (fallback: ${staticGasLimit})`) + swapLog(`${TAG} Estimated native deposit gas: ${gasLimit} (fallback: ${staticGasLimit})`) } catch (e: any) { console.warn(`${TAG} Gas estimation failed, using static fallback ${staticGasLimit}: ${e.message}`) gasLimit = staticGasLimit } } - const finalGasFee = gasPrice * gasLimit + const finalGasFee = reservedGasPrice * gasLimit // L2 chains (OP Stack): reserve extra for L1 data posting fee, which is separate from // gasPrice * gasLimit. Without this, sendMax overspends by the L1 fee and gets rejected. @@ -847,7 +1515,7 @@ async function buildEvmSwapTx( // Re-adjust for sendMax with refined gas estimate + L1 data fee buffer if (params.isMax && nativeBalance > totalGasReserve) { amountWei = nativeBalance - totalGasReserve - if (l1DataFeeBuffer > 0n) console.log(`${TAG} sendMax includes L1 data fee buffer: ${formatWei(l1DataFeeBuffer)}`) + if (l1DataFeeBuffer > 0n) swapLog(`${TAG} sendMax includes L1 data fee buffer: ${formatWei(l1DataFeeBuffer)}`) } if (nativeBalance < amountWei + totalGasReserve) { @@ -866,18 +1534,23 @@ async function buildEvmSwapTx( expiry, ) - const unsignedTx = { + const unsignedTx: any = { chainId, addressNList: fromChain.defaultPath, nonce: toHex(nonce), gasLimit: toHex(gasLimit), - gasPrice: toHex(gasPrice), - to: params.router, // ROUTER contract, NOT vault + to: routerAddress, // ROUTER contract, NOT vault value: toHex(amountWei), // ETH value sent with the call data: finalData, // depositWithExpiry encoded call } + if (maxFeePerGas) { + unsignedTx.maxFeePerGas = toHex(maxFeePerGas) + unsignedTx.maxPriorityFeePerGas = toHex(maxPriorityFeePerGas!) + } else { + unsignedTx.gasPrice = toHex(gasPrice) + } - console.log(`${TAG} EVM native router call: to=${params.router}, vault=${params.inboundAddress}, value=${formatWei(amountWei)} ${fromChain.symbol}${params.isMax ? ' (sendMax)' : ''}`) + swapLog(`${TAG} EVM native router call: to=${routerAddress}, vault=${params.inboundAddress}, value=${formatWei(amountWei)} ${fromChain.symbol}${params.isMax ? ' (sendMax)' : ''}`) return { unsignedTx } } } diff --git a/projects/keepkey-vault/src/bun/swap/classify.ts b/projects/keepkey-vault/src/bun/swap/classify.ts new file mode 100644 index 00000000..b52c1ed6 --- /dev/null +++ b/projects/keepkey-vault/src/bun/swap/classify.ts @@ -0,0 +1,180 @@ +/** + * classifySwapOutcome — pure function. Given a Midgard /v2/actions response + * (Maya or THORChain), produce a truthful classification of what happened. + * + * Replaces the Pioneer-trusting `mapPioneerStatus` for the slice we care about. + * The bug we're closing: a refund returns Pioneer status='completed' but + * Midgard's action.type='refund' — the truth lives in Midgard. The same + * mismatch makes us key the explorer URL on `toAsset.chainId` instead of the + * action's actual outbound chain (refunds outbound on the source chain, not + * the destination). + * + * Pure: no fetches, no side effects, deterministic given the input JSON. + * Test against real captured fixtures in __tests__/fixtures/swap/. + */ + +import { CHAINS } from '../../shared/chains' + +export type ClassifiedStatus = 'pending' | 'completed' | 'refunded' | 'failed' | 'unknown' + +export interface ClassifiedOutcome { + status: ClassifiedStatus + /** Hash of the inbound (user-signed) tx, if discoverable from the action. */ + inboundTxid: string | null + /** Hash of the outbound (vault-sent) tx — refund hash for refunds, delivery hash for completions. */ + outboundTxid: string | null + /** Maya/Thor asset string for the outbound, e.g. "ETH.ETH" or "ZEC.ZEC". */ + outboundAsset: string | null + /** vault chain id (e.g. 'ethereum', 'zcash') for the outbound — keys getExplorerTxUrl. */ + outboundChainId: string | null + /** Outbound amount in Maya 8-decimal base units (string to avoid float loss). */ + outboundAmount: string | null + /** Reason text from refund metadata, if present. */ + refundReason: string | null +} + +const UNKNOWN: ClassifiedOutcome = { + status: 'unknown', + inboundTxid: null, + outboundTxid: null, + outboundAsset: null, + outboundChainId: null, + outboundAmount: null, + refundReason: null, +} + +/** + * Map a Maya/Thor asset prefix ("ETH", "ZEC", "BTC", …) to the vault's + * internal chain id used for explorer URL lookup. Falls back to null if + * unknown — caller should suppress the explorer link rather than guess. + */ +function assetPrefixToChainId(prefix: string): string | null { + const sym = prefix.toUpperCase() + // Try symbol first (matches "BTC", "ZEC", "ETH", etc.) + const bySymbol = CHAINS.find(c => c.symbol === sym) + if (bySymbol) return bySymbol.id + // Then by Maya/Thor "chain" enum value (covers "GAIA"→cosmos, "THOR"→thorchain) + const byChain = CHAINS.find(c => (c.chain as string) === sym) + if (byChain) return byChain.id + // Maya-specific overrides where their naming differs from ours + switch (sym) { + case 'GAIA': return 'cosmos' + case 'THOR': return 'thorchain' + case 'MAYA': return 'mayachain' + default: return null + } +} + +/** + * Extract the chain prefix from a Maya/Thor asset string. + * Maya uses two separators interchangeably: + * - `.` for native assets ("ETH.ETH", "ETH.USDT-0xdac17...", "BTC.BTC") + * - `~` for synthetic/trade pool assets ("ZEC~ZEC", "BTC~BTC") + * Both forms appear in Midgard responses for the same chain — handle both. + */ +function chainPrefixFromAsset(asset: string): string | null { + if (!asset) return null + const sep = asset.search(/[.~]/) + if (sep < 0) return null + return asset.slice(0, sep) +} + +// ─── Subset of Midgard /v2/actions response we actually consume ─── +// Defined narrowly so future fields don't accidentally tighten the contract. + +interface MidgardCoin { amount: string; asset: string } +interface MidgardLeg { address?: string; coins: MidgardCoin[]; txID: string; height?: string } +interface MidgardAction { + type?: string // 'swap' | 'refund' | … + status?: string // 'success' | 'pending' | … + in?: MidgardLeg[] + out?: MidgardLeg[] + metadata?: { + refund?: { reason?: string; memo?: string } + swap?: { memo?: string } + } +} +export interface MidgardActionsResponse { + actions?: MidgardAction[] + count?: string +} + +/** + * Given a Midgard /v2/actions response, classify the outcome of the swap. + * + * Convention: we only look at `actions[0]`. Midgard returns at most one action + * per inbound txid; if multiple actions are returned the consumer asked for a + * different scope (and should pass them in one at a time). + */ +export function classifySwapOutcome(response: MidgardActionsResponse | null | undefined): ClassifiedOutcome { + if (!response?.actions?.length) return UNKNOWN + const action = response.actions[0] + if (!action) return UNKNOWN + + const inboundTxid = action.in?.[0]?.txID ?? null + const outboundLeg = action.out?.[0] ?? null + const outboundCoin = outboundLeg?.coins?.[0] ?? null + const outboundAsset = outboundCoin?.asset ?? null + const outboundAmount = outboundCoin?.amount ?? null + const outboundTxid = outboundLeg?.txID ?? null + const outboundChainId = outboundAsset + ? assetPrefixToChainId(chainPrefixFromAsset(outboundAsset) || '') + : null + + const type = (action.type || '').toLowerCase() + const remoteStatus = (action.status || '').toLowerCase() + + // Refund is the loud-case we keep getting wrong. Detect even when status + // is 'success' — Midgard reports "refund completed successfully", which + // we previously mapped to 'completed' and rendered with a green check. + if (type === 'refund') { + return { + status: remoteStatus === 'pending' ? 'pending' : 'refunded', + inboundTxid, + outboundTxid, + outboundAsset, + outboundChainId, + outboundAmount, + refundReason: cleanRefundReason(action.metadata?.refund?.reason ?? null), + } + } + + if (type === 'swap') { + // 'pending' => inbound observed, no outbound yet + if (remoteStatus === 'pending' || !outboundLeg) { + return { + status: 'pending', + inboundTxid, + outboundTxid: null, + outboundAsset: null, + outboundChainId: null, + outboundAmount: null, + refundReason: null, + } + } + if (remoteStatus === 'success') { + return { + status: 'completed', + inboundTxid, + outboundTxid, + outboundAsset, + outboundChainId, + outboundAmount, + refundReason: null, + } + } + } + + return { ...UNKNOWN, inboundTxid } +} + +/** + * Midgard prefixes refund reason strings with "MidgardBadUTF8EncodedBase64:" + * when they contain non-UTF-8 bytes (typically EVM calldata blobs in the + * memo). Strip the prefix so the surfaced reason is at least readable; the + * raw blob isn't useful to a user anyway. + */ +function cleanRefundReason(raw: string | null): string | null { + if (!raw) return null + return raw.replace(/^MidgardBadUTF8EncodedBase64:\s*/, '').trim() || null +} diff --git a/projects/keepkey-vault/src/bun/sweep-engine.ts b/projects/keepkey-vault/src/bun/sweep-engine.ts index 0a886394..3869a741 100644 --- a/projects/keepkey-vault/src/bun/sweep-engine.ts +++ b/projects/keepkey-vault/src/bun/sweep-engine.ts @@ -9,7 +9,7 @@ * check balances via Pioneer → build sweep tx → sign → broadcast. */ import { BTC_SCRIPT_TYPES, btcAccountPath } from '../shared/chains' -import { getPioneer, getPioneerApiBase } from './pioneer' +import { getPioneer } from './pioneer' import coinSelectSplit from 'coinselect/split' const TAG = '[sweep]' @@ -193,21 +193,23 @@ async function checkAddressBalance(address: string): Promise { async function fetchUtxos(address: string): Promise { try { - // Use blockbook API directly — Pioneer's ListUnspent needs xpub, not address - const base = getPioneerApiBase() - const resp = await fetch(`${base}/api/v2/utxo/${address}`) - if (!resp.ok) { - console.warn(`${TAG} Blockbook UTXO fetch failed for ${address}: ${resp.status}`) - return [] - } - const data = await resp.json() as any[] - if (!Array.isArray(data)) return [] + // Pioneer's ListUnspent endpoint accepts both xpubs AND single addresses + // (verified 2026-05-07). Path: /api/v1/utxo/unspent/{network}/{xpub-or-address}. + // Older code hit /api/v2/utxo/{address} on Pioneer's base URL — that route + // doesn't exist on pioneer-server (404), so the sweep tool was silently + // returning [] for every funded address found. + const pioneer = await getPioneer() + const resp = await pioneer.ListUnspent({ network: BTC_NETWORK_ID, xpub: address }) + const data = Array.isArray(resp) ? resp + : Array.isArray(resp?.data) ? resp.data + : Array.isArray(resp?.data?.data) ? resp.data.data + : [] return data.map((u: any) => ({ txid: u.txid, vout: u.vout, value: parseInt(u.value, 10) || 0, - hex: u.hex || undefined, + hex: u.tx?.hex || u.hex || undefined, })).filter((u: SweepUtxo) => u.value > 0) } catch (e: any) { console.warn(`${TAG} UTXO fetch failed for ${address}: ${e.message}`) @@ -217,11 +219,12 @@ async function fetchUtxos(address: string): Promise { async function fetchTxHex(txid: string): Promise { try { - const base = getPioneerApiBase() - const resp = await fetch(`${base}/api/v2/tx-specific/${txid}`) - if (!resp.ok) return undefined - const data = await resp.json() as any - return data?.hex || undefined + // Pioneer's tx lookup: /api/v1/utxo/lookup/{networkId}/{txid}. + // Older code hit /api/v2/tx-specific/{txid} on Pioneer's base URL — 404. + const pioneer = await getPioneer() + const resp = await pioneer.UtxoLookup({ networkId: BTC_NETWORK_ID, txid }) + const data = resp?.data || resp + return data?.hex || data?.tx?.hex || undefined } catch { return undefined } diff --git a/projects/keepkey-vault/src/bun/txbuilder/evm.ts b/projects/keepkey-vault/src/bun/txbuilder/evm.ts index c2996d21..8bd093fa 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/evm.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/evm.ts @@ -6,6 +6,7 @@ * Supports native ETH transfers and ERC-20 token transfers. */ import type { ChainDef } from '../../shared/chains' +import { tokenMaxSpendableBaseUnits } from '../../shared/max-send' import { getEvmGasPrice, getEvmNonce, getEvmBalance } from '../evm-rpc' const TAG = '[txbuilder:evm]' @@ -255,7 +256,7 @@ export async function buildEvmTx( throw new Error(`Cannot fetch token balance for max send: ${e.message}`) } } - amountBaseUnits = parseUnits(tokBalStr, tokenDecimals) + amountBaseUnits = tokenMaxSpendableBaseUnits(tokBalStr, tokenDecimals) ?? 0n if (amountBaseUnits <= 0n) throw new Error('Token balance is zero') } else { if (isNaN(amountNum) || amountNum <= 0) throw new Error('Invalid token amount') @@ -287,8 +288,10 @@ export async function buildEvmTx( let amountWei: bigint if (isMax) { - if (nativeBalance <= gasFee) throw new Error('Insufficient funds to cover gas fees') - amountWei = nativeBalance - gasFee * 110n / 100n // 10% gas buffer for safety + const gasReserve = (gasFee * 110n + 99n) / 100n // 10% gas buffer, rounded up + if (nativeBalance <= gasReserve) throw new Error('Insufficient funds to cover gas fees') + amountWei = nativeBalance - gasReserve + if (amountWei <= 0n) throw new Error('Insufficient funds to cover gas fees') } else { amountWei = parseUnits(String(params.amount), 18) if (amountWei + gasFee > nativeBalance && nativeBalance > 0n) { diff --git a/projects/keepkey-vault/src/bun/txbuilder/index.ts b/projects/keepkey-vault/src/bun/txbuilder/index.ts index 8b3eb830..b3256606 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/index.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/index.ts @@ -3,15 +3,90 @@ */ import type { ChainDef } from '../../shared/chains' import type { BuildTxParams } from '../../shared/types' +import { decimalToBaseUnitsStrict, tokenMaxSpendableAmount, tokenMaxSpendableBaseUnits } from '../../shared/max-send' import { buildUtxoTx, type BuildUtxoParams, type XpubInfo } from './utxo' import { buildEvmTx, type BuildEvmParams } from './evm' import { buildCosmosTx, type BuildCosmosParams } from './cosmos' import { buildXrpTx, type BuildXrpParams } from './xrp' import { sendShielded, type ShieldedSendParams } from './zcash-shielded' import { buildTonTransfer, assembleTonSignedBoc, getTonSeqno, getTonWalletState, broadcastTonBoc, type TonBuildResult } from './ton' +import { SOLANA_LAMPORTS_PER_SIGNATURE, solanaTransferLamportsForAmount } from './solana' +import { parseSolanaTx, solanaMessageSlice, SolanaTxParseError } from '../solana-tx' // Pioneer SDK instance is passed as parameter to buildTx() export type { BuildTxParams } +export { SOLANA_LAMPORTS_PER_SIGNATURE, solanaTransferLamportsForAmount } from './solana' + +const TRON_SUN_PER_TRX = 1_000_000n +const TRON_NATIVE_MAX_RESERVE_SUN = TRON_SUN_PER_TRX * 11n / 10n +const TRON_TOKEN_CONTRACT_RE = /^tron:[^/]+\/(?:token|trc20):(T[1-9A-HJ-NP-Za-km-z]{33})$/ + +async function fetchTronNativeBalanceSun(address: string): Promise { + const resp = await fetch('https://api.trongrid.io/wallet/getaccount', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, visible: true }), + }) + const data = await resp.json() as any + if (data?.Error) throw new Error(data.Error) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return BigInt(data?.balance ?? 0) +} + +/** + * Inject a memo into a TronGrid `triggersmartcontract` response so the + * THORChain swap memo lands in `Transaction.raw_data.data`. Returns a + * shallow-cloned `tronGridTx` with `raw_data_hex`, `raw_data.data`, and + * `txID` updated. + * + * Why protobuf splicing instead of a TronGrid parameter: `createtransaction` + * exposes `extra_data` which sets `raw_data.data` natively, but + * `triggersmartcontract` silently drops it. We have to inject the field + * ourselves, AND it must be in canonical tag-order position (field 10, + * before contract/11) — appending at the end produces a different sha256 + * than what TronGrid computes after canonicalization, so the device's + * signature would not verify at broadcast time. + */ +export async function injectTronMemo(tronGridTx: any, memo: string): Promise { + const protobuf = (await import('protobufjs/light')).default + const memoBytes = Buffer.from(memo, 'utf8') + const origBytes = Buffer.from(tronGridTx.raw_data_hex, 'hex') + + // Walk top-level wire format to find where field >= 11 starts (canonical + // insertion point for field 10). All TRON Transaction.raw fields below 10 + // must precede us; everything from contract(11) onwards must follow. + const reader = new protobuf.Reader(origBytes) + let insertAt: number | null = null + while (reader.pos < reader.len) { + const fieldStart = reader.pos + const tag = reader.uint32() + const fieldNum = tag >>> 3 + if (fieldNum >= 11 && insertAt === null) { + insertAt = fieldStart + break + } + reader.skipType(tag & 7) + } + if (insertAt === null) insertAt = origBytes.length + + const writer = new protobuf.Writer() + writer.uint32((10 << 3) | 2).bytes(memoBytes) // field 10, wire type 2 (length-delimited bytes) + const dataFieldBytes = Buffer.from(writer.finish()) + + const newBytes = Buffer.concat([ + origBytes.subarray(0, insertAt), + dataFieldBytes, + origBytes.subarray(insertAt), + ]) + const newTxID = Buffer.from(await crypto.subtle.digest('SHA-256', newBytes)).toString('hex') + + return { + ...tronGridTx, + raw_data_hex: newBytes.toString('hex'), + raw_data: { ...tronGridTx.raw_data, data: memoBytes.toString('hex') }, + txID: newTxID, + } +} /** * Build an unsigned transaction for any supported chain. @@ -105,17 +180,12 @@ export async function buildTx( const tokenDecimals = params.tokenDecimals // For MAX send, use frontend-provided tokenBalance (same pattern as EVM) const sendAmount = params.isMax && params.tokenBalance && parseFloat(params.tokenBalance) > 0 - ? params.tokenBalance + ? tokenMaxSpendableAmount(params.tokenBalance, tokenDecimals) : params.amount if (params.isMax && (!sendAmount || parseFloat(sendAmount) <= 0)) { throw new Error('Token balance is zero — cannot send max') } - const tokenAmountBase = (() => { - const parts = sendAmount.split('.') - const whole = parts[0] || '0' - const frac = (parts[1] || '').slice(0, tokenDecimals).padEnd(tokenDecimals, '0') - return String(BigInt(whole) * BigInt(10 ** tokenDecimals) + BigInt(frac)) - })() + const tokenAmountBase = decimalToBaseUnitsStrict(sendAmount, tokenDecimals).toString() console.debug(`[buildTx:solana] SPL token: decimals=${tokenDecimals}`) try { @@ -138,15 +208,33 @@ export async function buildTx( throw new Error(`SPL token tx build failed: ${e.message}`) } } else { - // Native SOL transfer — convert to lamports (9 decimals) - const solAmountLamports = (() => { - const parts = params.amount.split('.') - const whole = parts[0] || '0' - const frac = (parts[1] || '').slice(0, 9).padEnd(9, '0') - return String(BigInt(whole) * 1000000000n + BigInt(frac)) - })() - - console.debug(`[buildTx:solana] Native SOL transfer`) + // Native SOL transfer — convert to lamports (9 decimals). For regular + // Send MAX the UI submits amount='0' + isMax=true, so resolve the + // native balance here before subtracting the signature fee. + let solAmount = params.amount + if (params.isMax) { + try { + // Pioneer's GetBalanceAddressByNetwork uses the /evm/balance/ endpoint + // which rejects non-Ethereum addresses. Use Solana JSON-RPC directly. + const rpcResp = await fetch('https://api.mainnet-beta.solana.com', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getBalance', params: [params.fromAddress, { commitment: 'confirmed' }] }), + }) + const rpcJson = await rpcResp.json() as any + if (rpcJson.error) throw new Error(rpcJson.error.message || String(rpcJson.error)) + const lamports: number = rpcJson.result?.value ?? 0 + // Integer arithmetic to avoid floating-point precision loss + const whole = Math.floor(lamports / 1_000_000_000) + const frac = String(lamports % 1_000_000_000).padStart(9, '0') + solAmount = `${whole}.${frac}` + } catch (e: any) { + throw new Error(`Cannot fetch live SOL balance for max send: ${e.message}`) + } + } + const solAmountLamports = String(solanaTransferLamportsForAmount(solAmount, !!params.isMax)) + + console.debug(`[buildTx:solana] Native SOL transfer${params.isMax ? ` (max, reserved ${SOLANA_LAMPORTS_PER_SIGNATURE} lamports for fee)` : ''}`) try { const resp = await pioneer.BuildSolanaTransfer({ from: params.fromAddress, @@ -177,34 +265,185 @@ export async function buildTx( } case 'tron': { - // Tron — TronGrid builds the raw protobuf tx (raw_data_hex), device signs + // Tron — TronGrid builds the raw protobuf tx (raw_data_hex), device signs. + // Two paths: + // • TRC-20 token (caip contains `/token:T...` or `/trc20:T...`) — triggersmartcontract + // calling `transfer(address,uint256)` on the token contract. Used for + // USDT THORChain swaps. + // • Native TRX — createtransaction (TransferContract). + // For both, an optional `params.memo` (THORChain swap memo) is written + // into `Transaction.raw_data.data` via TronGrid's `extra_data` field. if (!params.fromAddress) throw new Error('fromAddress required for Tron') - // Convert TRX amount to sun (6 decimals) - const sunAmount = (() => { - const parts = params.amount.split('.') - const whole = parts[0] || '0' - const frac = (parts[1] || '').slice(0, 6).padEnd(6, '0') - // Keep as Number — TronGrid expects integer, and TRX max supply (99B) fits safely in Number - const sun = BigInt(whole) * 1000000n + BigInt(frac) - if (sun > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('TRX amount too large') - return Number(sun) + // TRC-20 detection: pioneer-router quote returns `txParams.token: "USDT-T..."` + // for tokens, and upstream CAIPs may use `tron:.../token:T...` or + // `tron:.../trc20:T...`. + const tokenContractFromCaip = (() => { + if (!params.caip) return null + const m = params.caip.match(TRON_TOKEN_CONTRACT_RE) + return m ? m[1] : null })() + const isTrc20 = !!tokenContractFromCaip + + // ── TRC-20 path (TriggerSmartContract → transfer(address,uint256)) ── + if (isTrc20) { + // USDT-on-TRON has 6 decimals. If we add other TRC-20s later, look the + // value up rather than hard-coding — but for the THORChain MVP, USDT is + // the only token in scope. + const tokenDecimals = params.tokenDecimals ?? 6 + + // ABI-encode the `transfer(address,uint256)` parameter pair. + // TronGrid's `function_selector` field tells it to prepend the 4-byte + // selector (0xa9059cbb) automatically, so `parameter` is just the + // 64 hex chars of the address slot + 64 hex chars of the amount slot. + // TRON's base58check address: [0x41][20 bytes hash][4 byte checksum]. + // Strip prefix + checksum to get the 20-byte EVM-style address slot. + const base58 = await import('bs58') + const tronAddressHashHex = (address: string): string => { + const decoded = base58.default.decode(address) + if (decoded.length !== 25 || decoded[0] !== 0x41) { + throw new Error(`Invalid TRON address: ${address}`) + } + return Buffer.from(decoded.slice(1, 21)).toString('hex') + } + + let tokenAmountBase: bigint + if (params.isMax) { + if (params.tokenBalance && parseFloat(params.tokenBalance) > 0) { + tokenAmountBase = tokenMaxSpendableBaseUnits(params.tokenBalance, tokenDecimals) ?? 0n + } else { + const ownerHashHex = tronAddressHashHex(params.fromAddress) + const ownerParameter = ownerHashHex.padStart(64, '0') + try { + const resp = await fetch('https://api.trongrid.io/wallet/triggerconstantcontract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: params.fromAddress, + contract_address: tokenContractFromCaip, + function_selector: 'balanceOf(address)', + parameter: ownerParameter, + visible: true, + }), + }) + const respJson = await resp.json() as any + if (respJson?.Error) throw new Error(respJson.Error) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const balanceHex = respJson?.constant_result?.[0] + if (!balanceHex) throw new Error('missing constant_result') + tokenAmountBase = BigInt(`0x${balanceHex}`) + } catch (e: any) { + throw new Error(`Cannot fetch TRC-20 balance for max send: ${e.message}`) + } + } + if (tokenAmountBase <= 0n) throw new Error('Token balance is zero — cannot send max') + } else { + tokenAmountBase = decimalToBaseUnitsStrict(params.amount, tokenDecimals) + if (tokenAmountBase <= 0n) throw new Error('Amount must be greater than zero') + } + + const recipientHashHex = tronAddressHashHex(params.to) + const amountHex = tokenAmountBase.toString(16).padStart(64, '0') + const parameter = recipientHashHex.padStart(64, '0') + amountHex + + // fee_limit caps the energy/TRX a smart contract call can burn — 30 TRX + // is generous for a USDT.transfer() (typical cost is ~14 TRX) and + // matches what TronLink defaults to for unknown contracts. + const FEE_LIMIT_SUN = 30_000_000 + + let tronGridTx: any + try { + console.debug(`[buildTx] TRON triggersmartcontract: USDT.transfer(${params.to}, ${tokenAmountBase.toString()}) at ${tokenContractFromCaip}`) + const resp = await fetch('https://api.trongrid.io/wallet/triggersmartcontract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: params.fromAddress, + contract_address: tokenContractFromCaip, + function_selector: 'transfer(address,uint256)', + parameter, + fee_limit: FEE_LIMIT_SUN, + call_value: 0, + visible: true, + }), + }) + const respJson = await resp.json() as any + if (respJson?.Error) throw new Error(respJson.Error) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + // triggersmartcontract returns { transaction: {...}, ... } + tronGridTx = respJson.transaction + if (!tronGridTx?.raw_data_hex) { + throw new Error('TronGrid triggersmartcontract did not return a transaction with raw_data_hex') + } + } catch (e: any) { + throw new Error(`Tron TRC-20 build failed: ${e.message}`) + } + + // Inject memo into raw_data.data. TronGrid's `triggersmartcontract` + // endpoint silently ignores `extra_data` (unlike `createtransaction`), + // so we have to splice the protobuf ourselves. The memo MUST land in + // canonical tag-order position (field 10, before contract/11) — if we + // appended at the end, TronGrid would re-canonicalize and the txID it + // computes would not match the one the device signed, breaking + // signature verification. + if (params.memo) { + tronGridTx = await injectTronMemo(tronGridTx, params.memo) + } + + // Intentionally do NOT pass `toAddress`/`amount` for TRC-20. + // Firmware 7.14's TRON FSM has only a TransferContract clear-sign + // path — given those fields it shows "Send TRX to + // " regardless of the actual contract being called. For a + // TRC-20 transfer that means the device shows "Send 1 TRX to " when the user is actually sending 1 USDT to a recipient + // — accurate to the bytes signed but a misleading interpretation. By + // omitting the hint fields the firmware falls back to the generic + // "Really sign this TRON transaction?" prompt, which matches the + // SwapDialog blind-sign warning text. Once firmware adds TRC-20 + // clear-signing, populate proper hint fields here. + const tronUnsignedTx = { + addressNList: chain.defaultPath, + rawTx: tronGridTx.raw_data_hex, + tronGridTx, + } + return { unsignedTx: tronUnsignedTx, fee: String(FEE_LIMIT_SUN / 1_000_000) } + } + + // ── Native TRX path (TransferContract) ── + // Convert TRX amount to sun (6 decimals) + const sunAmountBig = params.isMax + ? await (async () => { + const balanceSun = await fetchTronNativeBalanceSun(params.fromAddress!) + const spendableSun = balanceSun - TRON_NATIVE_MAX_RESERVE_SUN + if (spendableSun <= 0n) { + throw new Error(`Insufficient TRX for max send after reserving ${Number(TRON_NATIVE_MAX_RESERVE_SUN) / 1_000_000} TRX for network fees`) + } + return spendableSun + })() + : decimalToBaseUnitsStrict(params.amount, 6) + if (sunAmountBig <= 0n) throw new Error('Amount must be greater than zero') + if (sunAmountBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('TRX amount too large') + // Keep as Number — TronGrid expects integer, and TRX max supply (99B) fits safely in Number. + const sunAmount = Number(sunAmountBig) let tronGridTx: any try { // Use TronGrid's createtransaction — it returns raw_data_hex (serialized protobuf) - // which is exactly what the KeepKey firmware needs to sign. - console.debug(`[buildTx] TRON createtransaction: amount=${sunAmount} SUN`) + // which is exactly what the KeepKey firmware needs to sign. `extra_data` + // (when present) lands in `raw_data.data` verbatim — that's where THORChain's + // TRON observer reads the swap memo from. + console.debug(`[buildTx] TRON createtransaction: amount=${sunAmount} SUN${params.memo ? `, memo=${params.memo.length}b` : ''}`) + const body: any = { + owner_address: params.fromAddress, + to_address: params.to, + amount: sunAmount, + visible: true, + } + if (params.memo) body.extra_data = params.memo const resp = await fetch('https://api.trongrid.io/wallet/createtransaction', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - owner_address: params.fromAddress, - to_address: params.to, - amount: sunAmount, - visible: true, - }), + body: JSON.stringify(body), }) tronGridTx = await resp.json() as any if (tronGridTx?.Error) { @@ -229,7 +468,7 @@ export async function buildTx( tronGridTx, } // Tron: bandwidth is typically free for TRX transfers - return { unsignedTx: tronUnsignedTx, fee: '0' } + return { unsignedTx: tronUnsignedTx, fee: params.isMax ? String(Number(TRON_NATIVE_MAX_RESERVE_SUN) / 1_000_000) : '0' } } case 'ton': { @@ -320,15 +559,87 @@ export async function signTx( case 'xrp': return wallet.rippleSignTx(unsignedTx) case 'solana': { - console.debug(`[signTx:solana] signing tx`) - const solResult = await wallet.solanaSignTx(unsignedTx) - console.debug(`[signTx:solana] result: hasSig=${!!solResult?.signature} hasSerializedTx=${!!solResult?.serializedTx}`) - return solResult + // KeepKey firmware message type 752 (SolanaSignTx) parses LEGACY messages + // only. For versioned (v0) messages we route the exact message bytes + // through type 754 (SolanaSignMessage), preserving the 0x80 prefix and + // payload. Same logic as the solanaSignTx RPC handler in bun/index.ts — + // duplicated here because the swap path calls hdwallet directly and + // bypasses the RPC wrapper. Without this, Pioneer-built v0 swap txs + // (Solana inbound to THORChain etc.) hit "Malformed Solana transaction" + // from the device. + const fullTx = Buffer.from( + typeof unsignedTx.rawTx === 'string' + ? unsignedTx.rawTx + : Buffer.from(unsignedTx.rawTx).toString('base64'), + 'base64', + ) + let parsed + try { + parsed = parseSolanaTx(fullTx) + } catch (err) { + if (err instanceof SolanaTxParseError) throw new Error(`[signTx:solana] ${err.message}`) + throw err + } + console.debug(`[signTx:solana] signing tx versioned=${parsed.isVersioned} sigCount=${parsed.sigCount} fullTx=${fullTx.length}B`) + + let sigBytes: Uint8Array + if (parsed.isVersioned) { + const messageBytes = solanaMessageSlice(fullTx, parsed) + console.debug(`[signTx:solana] v0 — routing through solanaSignMessage (${messageBytes.length}B incl. 0x80 prefix)`) + const msgRes = await wallet.solanaSignMessage({ + addressNList: unsignedTx.addressNList, + message: messageBytes, + showDisplay: true, + }) + const sig = msgRes?.signature + if (!sig) throw new Error('[signTx:solana] v0: device returned no signature') + sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') + } else { + // Legacy: hdwallet expects the message bytes (no sig section), not the full tx. + const deviceParams = { + ...unsignedTx, + rawTx: Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64'), + } + const result = await wallet.solanaSignTx(deviceParams) + if (!result?.signature) { + // Device returned a non-signature result — pass through (preserves + // the existing legacy behaviour for callers expecting that shape). + console.debug(`[signTx:solana] legacy result has no signature, passing through`) + return result + } + sigBytes = result.signature instanceof Uint8Array + ? result.signature + : Buffer.from(result.signature, 'base64') + } + + if (sigBytes.length !== 64) { + throw new Error(`[signTx:solana] Unexpected signature length ${sigBytes.length}`) + } + // Splice the signature into the first sig slot of the original wire-format tx. + const rawBytes = Buffer.from(fullTx) + if (rawBytes.length < parsed.sigStart + 64) { + throw new Error('[signTx:solana] Raw tx too short to hold signature') + } + for (let i = 0; i < 64; i++) rawBytes[parsed.sigStart + i] = sigBytes[i] + const serializedTx = rawBytes.toString('base64') + console.debug(`[signTx:solana] assembled signed tx ${rawBytes.length}B (versioned=${parsed.isVersioned})`) + return { signature: sigBytes, serializedTx } + } + case 'tron': { + // hdwallet returns { signature, serializedTx, ... } but does NOT echo + // tronGridTx — and broadcastTx() reassembles the broadcast envelope by + // splicing the signature into tronGridTx. Without re-merging here, the + // swap orchestrator hits "Tron broadcast requires tronGridTx and + // signature" because tronGridTx was lost between build and broadcast. + const tronResult = await wallet.tronSignTx(unsignedTx) + return { ...tronResult, tronGridTx: unsignedTx.tronGridTx } + } + case 'ton': { + // Same pattern as Tron: preserve tonBuildResult through signing so the + // BOC-assembly step in broadcastTx has the build context it needs. + const tonResult = await wallet.tonSignTx(unsignedTx) + return { ...tonResult, tonBuildResult: unsignedTx.tonBuildResult } } - case 'tron': - return wallet.tronSignTx(unsignedTx) - case 'ton': - return wallet.tonSignTx(unsignedTx) case 'zcash-shielded': // Shielded signing is handled by the zcash-shielded module (sidecar + device) // The full flow is orchestrated by sendShielded() — this should not be called directly @@ -378,8 +689,23 @@ export async function broadcastTx( // Tron: broadcast via TronGrid's broadcasttransaction (JSON format, needs raw_data + signature) if (chain.chainFamily === 'tron') { const tronGridTx = signedTx?.tronGridTx - const sigHex = signedTx?.signature - if (!tronGridTx || !sigHex) throw new Error('Tron broadcast requires tronGridTx and signature') + const rawSig = signedTx?.signature + if (!tronGridTx || !rawSig) throw new Error('Tron broadcast requires tronGridTx and signature') + + // hdwallet's tronSignTx returns `signature` as a Uint8Array. TronGrid + // expects a hex string in the `signature` array — JSON.stringify on a + // Uint8Array produces `{"0":n,"1":n,...}` (array-like-as-object), which + // TronGrid's parser chokes on with a Java NullPointerException. Normalize + // to lowercase hex regardless of the input shape. + const sigHex = + rawSig instanceof Uint8Array || Buffer.isBuffer(rawSig) + ? Buffer.from(rawSig).toString('hex') + : typeof rawSig === 'string' + ? rawSig.replace(/^0x/, '') + : null + if (!sigHex || !/^[0-9a-fA-F]+$/.test(sigHex)) { + throw new Error(`Tron broadcast: signature is malformed (got ${typeof rawSig}, length=${(rawSig as any)?.length ?? 'n/a'})`) + } const broadcastBody = { ...tronGridTx, signature: [sigHex] } const resp = await fetch('https://api.trongrid.io/wallet/broadcasttransaction', { @@ -389,11 +715,36 @@ export async function broadcastTx( }) const data = await resp.json() as any if (data?.result === true && data?.txid) return { txid: data.txid } - // TronGrid returns error messages as hex-encoded strings - let errMsg = data?.code || 'Unknown error' - if (data?.message) { - try { errMsg = Buffer.from(data.message, 'hex').toString('utf8') } catch { errMsg = data.message } - } + + // Diagnostic: TronGrid uses several error shapes: + // { result: false, code: "CONTRACT_VALIDATE_ERROR", message: "" } + // { result: false, message: "" } + // { Error: "..." } (uppercase, validation failure) + // { code: "...", message: "" } + // { error: "..." } (generic HTTP-style) + // The hex-encoded `message` is the human-readable failure reason. + // Falling back to JSON.stringify so we never throw a bare "Unknown error". + const errMsgFromHexMessage = (() => { + const raw = data?.message + if (typeof raw !== 'string' || raw.length === 0) return null + // Hex string: even-length, all 0-9a-fA-F + if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) { + try { + const decoded = Buffer.from(raw, 'hex').toString('utf8') + if (decoded && /[\x20-\x7e]/.test(decoded)) return decoded + } catch { /* fall through */ } + } + return raw + })() + const errMsg = + errMsgFromHexMessage || + data?.Error || // capitalized: validation + data?.error || // generic + data?.code || // category + (resp.status !== 200 ? `HTTP ${resp.status} ${resp.statusText}` : null) || + `unparseable response: ${JSON.stringify(data).slice(0, 240)}` + console.error('[broadcast:tron] TronGrid rejected. body:', JSON.stringify(broadcastBody).slice(0, 400)) + console.error('[broadcast:tron] TronGrid response:', JSON.stringify(data).slice(0, 400)) throw new Error(`Tron broadcast failed: ${errMsg}`) } diff --git a/projects/keepkey-vault/src/bun/txbuilder/solana.ts b/projects/keepkey-vault/src/bun/txbuilder/solana.ts new file mode 100644 index 00000000..6147381f --- /dev/null +++ b/projects/keepkey-vault/src/bun/txbuilder/solana.ts @@ -0,0 +1,22 @@ +export const SOLANA_LAMPORTS_PER_SIGNATURE = 5000n +const SOLANA_DECIMALS = 9 + +function decimalAmountToBaseUnits(amount: string, decimals: number): bigint { + const normalized = String(amount || '').trim() + if (!/^\d*(?:\.\d*)?$/.test(normalized) || normalized === '' || normalized === '.') { + throw new Error(`Invalid amount: ${amount}`) + } + const parts = normalized.split('.') + const whole = parts[0] || '0' + const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0') + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(frac) +} + +export function solanaTransferLamportsForAmount(amount: string, isMax = false): bigint { + const lamports = decimalAmountToBaseUnits(amount, SOLANA_DECIMALS) + if (!isMax) return lamports + if (lamports <= SOLANA_LAMPORTS_PER_SIGNATURE) { + throw new Error('Insufficient SOL for max swap: balance does not cover the Solana network fee') + } + return lamports - SOLANA_LAMPORTS_PER_SIGNATURE +} diff --git a/projects/keepkey-vault/src/bun/txbuilder/ton.ts b/projects/keepkey-vault/src/bun/txbuilder/ton.ts index d573c1a1..f925d2b6 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/ton.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/ton.ts @@ -609,6 +609,42 @@ export function buildTonTransfer(params: { } } +/** + * Recompute the unsigned body hash from a build result. Used by + * /ton/finalize-transfer to detect clients that mutated _internal (amount, + * destination, memo, seqno, expireAt) after the device already signed the + * original bodyHash. Without this check, a tampered build would assemble + * into a BOC with a valid-looking structure but an invalid signature — the + * caller gets back a "successful" response that silently fails at broadcast. + * + * Throws on malformed _internal (bad hex, out-of-range BigInt, etc.) so the + * caller can surface a 400 instead of a cryptic assembler error. + */ +export function computeTonBodyHash(build: TonBuildResult): string { + const int = build?._internal + if (!int) throw new Error('build._internal missing') + if (typeof int.destHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(int.destHash)) { + throw new Error('build._internal.destHash must be 32-byte hex') + } + if (typeof int.amountNano !== 'string' || int.amountNano.length === 0) { + throw new Error('build._internal.amountNano must be a decimal string') + } + if (!Number.isInteger(int.destWorkchain)) { + throw new Error('build._internal.destWorkchain must be an integer') + } + if (!Number.isInteger(build.seqno) || build.seqno < 0) { + throw new Error('build.seqno must be a non-negative integer') + } + if (!Number.isInteger(build.expireAt) || build.expireAt < 0) { + throw new Error('build.expireAt must be a non-negative integer') + } + const destHash = Buffer.from(int.destHash, 'hex') + const amountNano = BigInt(int.amountNano) // throws on malformed + const internalMsg = buildInternalMessage(int.destWorkchain, destHash, amountNano, !!int.bounce, int.memo) + const unsignedBody = buildUnsignedBody(build.seqno, build.expireAt, internalMsg) + return cellHash(unsignedBody).toString('hex') +} + /** Assemble the signed BOC from build result + 64-byte Ed25519 signature → { boc, extMsgHash } */ export function assembleTonSignedBoc( buildResult: TonBuildResult, diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts index 00b2a84c..8c8df615 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts @@ -52,13 +52,17 @@ let deshieldInProgress = false export async function deshieldZec( wallet: any, params: DeshieldParams, + opts?: { + signWrap?: import("./zcash-shielded").DeviceSignWrap; + onProgress?: import("./zcash-shield").TxProgressFn; + }, ): Promise<{ txid: string }> { if (deshieldInProgress) { throw new Error("A deshield transaction is already in progress") } deshieldInProgress = true try { - return await _deshieldZecInner(wallet, params) + return await _deshieldZecInner(wallet, params, opts) } finally { deshieldInProgress = false } @@ -67,6 +71,10 @@ export async function deshieldZec( async function _deshieldZecInner( wallet: any, params: DeshieldParams, + opts?: { + signWrap?: import("./zcash-shielded").DeviceSignWrap; + onProgress?: import("./zcash-shield").TxProgressFn; + }, ): Promise<{ txid: string }> { const account = params.account ?? 0 @@ -101,11 +109,13 @@ async function _deshieldZecInner( // 2. Device signs Orchard actions (same as shielded send — no transparent signing needed) console.log("[zcash-deshield] Requesting device signatures...") + opts?.onProgress?.("signing") if (typeof wallet.zcashSignPczt !== "function") { throw new Error("hdwallet does not support zcashSignPczt — ensure Zcash-capable firmware") } - const signatures = await wallet.zcashSignPczt(sr, sr.sighash) + const signFn = () => wallet.zcashSignPczt(sr, sr.sighash) + const signatures = opts?.signWrap ? await opts.signWrap(signFn) : await signFn() if (!signatures || !Array.isArray(signatures)) { throw new Error("Device did not return signatures") } @@ -124,8 +134,11 @@ async function _deshieldZecInner( } console.log(`[zcash-deshield] raw_tx length: ${raw_tx.length / 2} bytes`) console.log("[zcash-deshield] Broadcasting...") + opts?.onProgress?.("broadcasting") await sendCommand("broadcast", { raw_tx }) - console.log(`[zcash-deshield] Deshield transaction sent: ${txid}`) - return { txid } + const { txidToDisplayOrder } = await import("./zcash-shield") + const displayTxid = txidToDisplayOrder(txid) + console.log(`[zcash-deshield] Deshield transaction sent: ${displayTxid}`) + return { txid: displayTxid } } diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts index 82d1f56b..9c255140 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts @@ -10,7 +10,7 @@ * 6. Broadcast via lightwalletd */ -import { sendCommand, isSidecarReady, startSidecar, getCachedFvk } from "../zcash-sidecar" +import { sendCommand, isSidecarReady, startSidecar, getCachedFvk, getScanState } from "../zcash-sidecar" import { initializeOrchardFromDevice } from "./zcash-shielded" /** Compute P2PKH scriptPubKey from compressed pubkey hex: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG */ @@ -58,6 +58,10 @@ interface TransparentUtxo { vout: number value: number scriptPubKey: string + /** Confirmation count if Pioneer provides one — used by the 10-conf gate. */ + confirmations?: number + /** Block height the UTXO was mined at, if reported. */ + height?: number } interface TransparentSigningInput { @@ -84,17 +88,90 @@ interface ShieldBuildResult { */ let shieldInProgress = false +export type TxProgressStep = "building" | "signing" | "broadcasting" | "complete" +export type TxProgressFn = (step: TxProgressStep, detail?: any) => void + +export const SHIELD_MIN_CONFIRMATIONS = 10 + +/** UTXO totals at a single transparent ZEC address, split by maturity. + * + * - `matureZat` is the only thing shieldZec can actually spend right now; + * the builder enforces the same 10-conf filter (reorgs can move recent + * transparent inputs, so signing against them produces a doomed tx). + * - `pendingZat` covers UTXOs still under 10 conf — exposed so the UI can + * surface "X ZEC pending, available in Y blocks" instead of a silent gap + * between displayed balance and shieldable amount. + * + * Uses Pioneer ListUnspent (same source the shield builder uses) scoped to + * one address — chain-level getBalance returns the whole xpub which is the + * wrong frame for the shield flow. */ +export async function getShieldableTransparentBalance( + pioneer: any, + transparentAddress: string, + tipHeight?: number | null, +): Promise<{ matureZat: number; pendingZat: number; matureCount: number; pendingCount: number }> { + const result = await pioneer.ListUnspent({ network: "ZEC", xpub: transparentAddress }) + const utxoArray = Array.isArray(result) ? result + : Array.isArray(result?.data) ? result.data + : Array.isArray(result?.utxos) ? result.utxos + : [] + + let matureZat = 0, pendingZat = 0, matureCount = 0, pendingCount = 0 + for (const u of utxoArray) { + const raw = String(u.value ?? u.amount ?? "0") + const value = raw.includes(".") + ? Math.round(parseFloat(raw) * 1e8) + : parseInt(raw, 10) + if (isNaN(value) || value <= 0) continue + + // Mirror the shield builder's confirmation gate so Available + Max stay + // honest: prefer Pioneer's `confirmations`, fall back to deriving from + // `height` against the sidecar's latest scanned tip. If neither is + // available, treat as mature (matches the builder's "don't block" rule). + let isMature = true + if (typeof u.confirmations === "number") { + isMature = u.confirmations >= SHIELD_MIN_CONFIRMATIONS + } else if (typeof u.height === "number" && tipHeight != null && u.height > 0) { + isMature = (tipHeight - u.height + 1) >= SHIELD_MIN_CONFIRMATIONS + } + + if (isMature) { matureZat += value; matureCount++ } + else { pendingZat += value; pendingCount++ } + } + return { matureZat, pendingZat, matureCount, pendingCount } +} + +/** Convert a sidecar-returned txid to explorer/display order. + * + * The sidecar (modules/keepkey-zcash) emits txids in the raw blake2b + * internal byte order — Zcash explorers (Blockchair, ZecRocks, etc.) + * show the byte-reversed form, like Bitcoin. There's an upstream fix + * in keepkey-zcash that reverses inside the Rust code before hex-encoding, + * but that lives in a separate repo with its own release cycle. Until + * every shipping vault has a sidecar with the fix baked in, we reverse + * here as the single defensive choke point. Once we can guarantee the + * sidecar emits display order, drop this helper and the call sites. */ +export function txidToDisplayOrder(internalHex: string): string { + if (!internalHex || internalHex.length !== 64 || !/^[0-9a-f]+$/i.test(internalHex)) { + // Don't silently mangle — surface bad input rather than producing a plausible-looking wrong txid + return internalHex + } + const bytes = internalHex.match(/.{2}/g)! + return bytes.reverse().join("").toLowerCase() +} + export async function shieldZec( wallet: any, pioneer: any, params: ShieldParams, + opts?: { signWrap?: import("./zcash-shielded").DeviceSignWrap; onProgress?: TxProgressFn }, ): Promise<{ txid: string }> { if (shieldInProgress) { throw new Error("A shield transaction is already in progress") } shieldInProgress = true try { - return await _shieldZecInner(wallet, pioneer, params) + return await _shieldZecInner(wallet, pioneer, params, opts) } finally { shieldInProgress = false } @@ -104,6 +181,7 @@ async function _shieldZecInner( wallet: any, pioneer: any, params: ShieldParams, + opts?: { signWrap?: import("./zcash-shielded").DeviceSignWrap; onProgress?: TxProgressFn }, ): Promise<{ txid: string }> { const account = params.account ?? 0 @@ -198,11 +276,22 @@ async function _shieldZecInner( const value = raw.includes('.') ? Math.round(parseFloat(raw) * 1e8) : parseInt(raw, 10) + // Pull a confirmation count when Pioneer provides one. Some UTXO + // indexers return `confirmations` directly; others return a `height` + // that we'd compare against tip. We defensively keep both forms. + const confirmations = typeof u.confirmations === 'number' + ? u.confirmations + : (typeof u.confirmations === 'string' ? parseInt(u.confirmations, 10) : undefined) + const height = typeof u.height === 'number' + ? u.height + : (typeof u.height === 'string' ? parseInt(u.height, 10) : undefined) return { txid: u.txid || u.tx_hash, vout: u.vout ?? u.tx_output_n ?? u.index ?? 0, value: isNaN(value) ? 0 : value, scriptPubKey: u.scriptPubKey || u.script || u.scriptpubkey || "", + confirmations: Number.isFinite(confirmations as number) ? (confirmations as number) : undefined, + height: Number.isFinite(height as number) ? (height as number) : undefined, } }) } catch (e: any) { @@ -213,55 +302,95 @@ async function _shieldZecInner( throw new Error("No transparent UTXOs found for shielding") } + // Min-confirmations gate (matches the Orchard 10-conf rule in the sidecar). + // Reorgs can move recent transparent inputs the same way they can move recent + // shielded notes; signing against an unconfirmed UTXO that later disappears + // produces a doomed tx. 10 matches zcashd / ywallet defaults. + // + // Pioneer's UTXO indexer may report `confirmations` directly OR just `height`. + // We prefer `confirmations` (no tip lookup needed); when only `height` is + // present we derive confirmations from `synced_to` (the sidecar's latest + // scanned block height, ≈ chain tip after the auto-scan that runs upstream + // of every send). If neither is present we let the UTXO through rather than + // blocking the user — better to broadcast and have the chain reject than to + // fail with a confusing UI error when the indexer schema changes. + const MIN_CONFIRMATIONS = 10 + const tipHeight = getScanState().syncedTo + const filtered = utxos.filter(u => { + if (typeof u.confirmations === 'number') return u.confirmations >= MIN_CONFIRMATIONS + if (typeof u.height === 'number' && tipHeight != null && u.height > 0) { + const derived = tipHeight - u.height + 1 + return derived >= MIN_CONFIRMATIONS + } + // No confirmation info we can use: don't block the send. + return true + }) + if (filtered.length === 0 && utxos.length > 0) { + throw new Error( + `All ${utxos.length} transparent UTXOs are within ${MIN_CONFIRMATIONS} confirmations of the chain tip. ` + + `Wait a few minutes and retry.` + ) + } + if (filtered.length < utxos.length) { + const filteredOut = utxos.length - filtered.length + console.log(`[zcash-shield] Filtered out ${filteredOut} UTXO(s) below ${MIN_CONFIRMATIONS} confirmations`) + } + utxos = filtered + const totalAvailable = utxos.reduce((sum, u) => sum + u.value, 0) - console.log(`[zcash-shield] Found ${utxos.length} UTXOs totaling ${totalAvailable} ZAT`) - - // 3. Coin selection — select UTXOs covering amount + fee - // ZIP-317: fee = 5000 × max(grace_actions, logical_actions) - // Shield-wrap logical_actions = max(transparent_in, transparent_out) + nActionsOrchard - // Orchard always pads to ≥ 2 actions; transparent has ≥ 1 input. - // Change output adds 1 transparent output, so: max(nInputs, 1) + 2 - // We compute this conservatively before coin selection; re-check after. - const nOrchardActions = 2 // Builder always pads to minimum 2 - const estimatedTransparent = 1 // At least 1 input, 1 change output → max(1,1) = 1 - const logicalActions = estimatedTransparent + nOrchardActions - const fee = 5000 * Math.max(2, logicalActions) // ZIP-317 - const target = params.amount + fee - - if (totalAvailable < target) { + console.log(`[zcash-shield] Found ${utxos.length} UTXOs totaling ${totalAvailable} ZAT (after ${MIN_CONFIRMATIONS}-conf filter)`) + + // 3. Coin selection — iteratively add UTXOs and recompute the ZIP-317 fee. + // + // The fee depends on how many inputs we end up selecting: + // logical_actions = max(transparent_inputs, transparent_outputs=1) + // + max(orchard_spends, orchard_outputs) // = 2 (BundleType::DEFAULT pad) + // fee = 5000 * max(grace_actions=2, logical_actions) + // + // So adding inputs can raise the fee, which can require even more inputs + // to cover the new target. The previous greedy version selected against a + // fixed (1-input) target and then threw if the recomputed fee outran the + // selected total — even when more UTXOs were available. Now we recompute + // after each addition and keep going until the running total covers the + // running target, only erroring out if the entire set is short. + const nOrchardActions = 2 // BundleType::DEFAULT pads to a 2-action minimum + const computeFee = (nInputs: number): number => { + const transparentActions = Math.max(nInputs, 1) // max(inputs, change_outputs=1) + const logical = transparentActions + nOrchardActions + return 5000 * Math.max(2, logical) + } + + // Cheap fast-path: even with 1 input (cheapest fee shape) we can't cover + // `amount + fee`, no point selecting. + const minFee = computeFee(1) + if (totalAvailable < params.amount + minFee) { throw new Error( - `Insufficient transparent balance: have ${totalAvailable} ZAT, need ${target} ZAT ` + - `(${params.amount} amount + ${fee} fee)` + `Insufficient transparent balance: have ${totalAvailable} ZAT, need ≥${params.amount + minFee} ZAT ` + + `(${params.amount} amount + ${minFee} fee minimum)` ) } - // Simple greedy selection — sort by value descending, take until covered const sorted = [...utxos].sort((a, b) => b.value - a.value) const selected: TransparentUtxo[] = [] let selectedTotal = 0 + let runningFee = computeFee(0) for (const utxo of sorted) { selected.push(utxo) selectedTotal += utxo.value - if (selectedTotal >= target) break + runningFee = computeFee(selected.length) + if (selectedTotal >= params.amount + runningFee) break } - console.log(`[zcash-shield] Selected ${selected.length} UTXOs totaling ${selectedTotal} ZAT`) - - // Re-check fee after coin selection — more inputs = more logical actions - const actualTransparent = Math.max(selected.length, 1) // max(inputs, change_outputs) - const actualLogical = actualTransparent + nOrchardActions - const actualFee = 5000 * Math.max(2, actualLogical) - if (actualFee > fee) { - console.log(`[zcash-shield] ZIP-317 fee adjusted: ${fee} → ${actualFee} ZAT (${actualLogical} logical actions)`) - // Re-select with higher fee if needed - if (selectedTotal < params.amount + actualFee) { - throw new Error( - `Insufficient balance after ZIP-317 fee adjustment: have ${selectedTotal} ZAT, ` + - `need ${params.amount + actualFee} ZAT (${params.amount} + ${actualFee} fee for ${actualLogical} actions)` - ) - } + const finalFee = computeFee(selected.length) + if (selectedTotal < params.amount + finalFee) { + throw new Error( + `Insufficient transparent balance after ZIP-317 fee for ${selected.length} input(s): ` + + `have ${selectedTotal} ZAT, need ${params.amount + finalFee} ZAT ` + + `(${params.amount} amount + ${finalFee} fee for ${selected.length + nOrchardActions} logical actions)` + ) } - const finalFee = Math.max(fee, actualFee) + + console.log(`[zcash-shield] Selected ${selected.length} UTXOs totaling ${selectedTotal} ZAT, fee=${finalFee} ZAT`) // Derive scriptPubKey from pubkey if UTXOs don't have it (Pioneer often omits it) const derivedScriptPubKey = await p2pkhScriptPubKey(compressedPubkey!) @@ -297,6 +426,7 @@ async function _shieldZecInner( // requires firmware support that may not be present. Check first and // fall back to Orchard-only signing with a clear error for transparent. console.log("[zcash-shield] Requesting device signatures...") + opts?.onProgress?.("signing") const hasTransparentInputs = buildResult.transparent_inputs.length > 0 @@ -317,7 +447,8 @@ async function _shieldZecInner( let signatures: any try { - signatures = await wallet.zcashSignPczt(signingRequest, buildResult.orchard_signing_request.sighash) + const signFn = () => wallet.zcashSignPczt(signingRequest, buildResult.orchard_signing_request.sighash) + signatures = opts?.signWrap ? await opts.signWrap(signFn) : await signFn() } catch (e: any) { if (e?.message?.includes("Unknown message") && hasTransparentInputs) { throw new Error( @@ -351,8 +482,10 @@ async function _shieldZecInner( console.log(`[zcash-shield] raw_tx (first 200): ${raw_tx?.slice(0, 200)}`) console.log(`[zcash-shield] raw_tx length: ${raw_tx?.length / 2} bytes`) console.log("[zcash-shield] Broadcasting...") + opts?.onProgress?.("broadcasting") await sendCommand("broadcast", { raw_tx }) - console.log(`[zcash-shield] Shield transaction sent: ${txid}`) - return { txid } + const displayTxid = txidToDisplayOrder(txid) + console.log(`[zcash-shield] Shield transaction sent: ${displayTxid}`) + return { txid: displayTxid } } diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts index 4dec0dcd..622e785c 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts @@ -11,7 +11,7 @@ * 5. Sidecar (or Pioneer API) broadcasts */ -import { sendCommand, isSidecarReady, startSidecar } from "../zcash-sidecar" +import { sendCommand, isSidecarReady, startSidecar, setCachedFvk, hasFvkLoaded } from "../zcash-sidecar" export interface ShieldedSendParams { /** Hex-encoded Orchard recipient address (43 bytes) */ @@ -24,6 +24,31 @@ export interface ShieldedSendParams { memo?: string } +/** + * Optional wrapper around the device-signing call. When the emulator is the + * active transport, the caller passes a function that pops the user-approval + * UI and pre-writes ButtonAck + DebugLinkDecision into the firmware's confirm + * loop. Without this on the emulator, the firmware busy-loops in + * confirm_helper() and the watchdog SIGKILLs the bun process. + */ +export type DeviceSignWrap = (fn: () => Promise) => Promise + +export async function displayOrchardAddressOnDevice(wallet: any, account: number = 0): Promise<{ address: string }> { + if (typeof wallet.zcashDisplayAddress !== "function") { + throw new Error( + "View on device requires firmware 7.15.0+ with ZcashDisplayAddress and a matching hdwallet wrapper" + ) + } + + const H = 0x80000000 + const result = await wallet.zcashDisplayAddress({ + addressNList: [H + 32, H + 133, H + account], + account, + }) + if (!result?.address) throw new Error("Device did not return a Zcash address") + return { address: result.address } +} + export interface SigningRequest { n_actions: number account: number @@ -126,9 +151,30 @@ export async function initializeOrchardFromDevice(wallet: any, account: number = // Send FVK components to sidecar console.log("[zcash-shielded] Setting FVK on sidecar...") const result = await sendCommand("set_fvk", { ak: akHex, nk: nkHex, rivk: rivkHex }) + // Update the in-process cache so hasFvkLoaded() / getCachedFvk() see the + // new FVK without each caller having to remember to call setCachedFvk(). + setCachedFvk(result.address, result.fvk) return { fvk: result.fvk, address: result.address } } +/** + * Ensure the sidecar is running and the FVK is loaded before any operation + * that touches notes (scan, balance, build, send). Direct RPC / REST callers + * may not have gone through the Privacy tab's auto-init path, so the FVK + * cache could be empty even when the device supports Orchard. Without this, + * `scanOrchardNotes` / `buildShieldedTx` etc. would hit a sidecar with no + * FVK and fail with "No FVK set". + */ +export async function ensureFvkLoaded(wallet: any, account: number = 0): Promise { + if (!isSidecarReady()) { + await startSidecar() + } + if (!hasFvkLoaded()) { + console.log("[zcash] FVK not loaded — initializing from device before scan/send...") + await initializeOrchardFromDevice(wallet, account) + } +} + /** * Scan the Zcash chain for Orchard notes. * Resumes from last scan position automatically. @@ -151,10 +197,21 @@ export async function scanOrchardNotes(startHeight?: number, fullRescan?: boolea /** * Get the current shielded balance (in zatoshis). + * + * `confirmed` and `notes_unspent` reflect every unspent note (regardless of + * depth). `spendable_confirmed` and `spendable_notes_count` only count notes + * deeper than `min_confirmations` from `synced_to` — that's the set the + * builder will actually accept, so UI controls (Max button, available-to-send) + * should use these. */ export async function getShieldedBalance(): Promise<{ confirmed: number pending: number + notes_unspent?: number + spendable_confirmed?: number + spendable_notes_count?: number + min_confirmations?: number + synced_to?: number | null }> { if (!isSidecarReady()) { throw new Error("Sidecar not initialized — call initializeOrchard() first") @@ -225,13 +282,14 @@ let sendInProgress = false export async function sendShielded( wallet: any, params: ShieldedSendParams, + opts?: { signWrap?: DeviceSignWrap; onProgress?: import("./zcash-shield").TxProgressFn }, ): Promise<{ txid: string }> { if (sendInProgress) { throw new Error("A shielded send is already in progress — wait for it to complete") } sendInProgress = true try { - return await _sendShieldedInner(wallet, params) + return await _sendShieldedInner(wallet, params, opts) } finally { sendInProgress = false } @@ -240,6 +298,7 @@ export async function sendShielded( async function _sendShieldedInner( wallet: any, params: ShieldedSendParams, + opts?: { signWrap?: DeviceSignWrap; onProgress?: import("./zcash-shield").TxProgressFn }, ): Promise<{ txid: string }> { // 0. Ensure sidecar is running and FVK is set if (!isSidecarReady()) { @@ -267,7 +326,10 @@ async function _sendShieldedInner( // ZcashSignPCZT (digests + metadata) → ZcashPCZTActionAck // For each action: ZcashPCZTAction (fields) → ZcashPCZTActionAck | ZcashSignedPCZT console.log("[zcash-shielded] Requesting device signatures...") - const signatures = await deviceSign(wallet, signing_request) + opts?.onProgress?.("signing") + const signatures = opts?.signWrap + ? await opts.signWrap(() => deviceSign(wallet, signing_request)) + : await deviceSign(wallet, signing_request) console.log(`[zcash-shielded] Got ${signatures.length} signatures`) // 3. Finalize via sidecar (apply sigs + binding sig + serialize) @@ -276,10 +338,13 @@ async function _sendShieldedInner( // 4. Broadcast console.log("[zcash-shielded] Broadcasting...") + opts?.onProgress?.("broadcasting") await broadcastShieldedTx(raw_tx) - console.log(`[zcash-shielded] Transaction sent: ${txid}`) - return { txid } + const { txidToDisplayOrder } = await import("./zcash-shield") + const displayTxid = txidToDisplayOrder(txid) + console.log(`[zcash-shielded] Transaction sent: ${displayTxid}`) + return { txid: displayTxid } } /** diff --git a/projects/keepkey-vault/src/bun/walletconnect.ts b/projects/keepkey-vault/src/bun/walletconnect.ts index 95eafa14..594063d8 100644 --- a/projects/keepkey-vault/src/bun/walletconnect.ts +++ b/projects/keepkey-vault/src/bun/walletconnect.ts @@ -10,8 +10,55 @@ import { Web3Wallet, type Web3WalletTypes } from '@walletconnect/web3wallet' import { buildApprovedNamespaces, getSdkError } from '@walletconnect/utils' import { formatJsonRpcResult, formatJsonRpcError } from '@walletconnect/jsonrpc-utils' import type { SessionTypes, SignClientTypes } from '@walletconnect/types' +import bs58 from 'bs58' import type { SigningRequestInfo, WcSessionInfo } from '../shared/types' import { evmAddressPath } from './evm-addresses' +import { parseSolanaTx } from './solana-tx' +import { buildSolanaMessageDecodedInfo } from './solana-message-preview' + +function base64ToBase58(base64: string): string { + return bs58.encode(Buffer.from(base64, 'base64')) +} + +/** Throws if the dApp-supplied signer address doesn't match the account + * that the WC session was approved with. Without this, a malicious dApp + * could pair on account A and request a signature claiming account B — + * we'd display B in the approval but sign with A's key. */ +function assertSignerMatches(requested: string | undefined, selected: string) { + if (!requested) return // dApp omitted; trust the WC session account + if (requested.toLowerCase() !== selected.toLowerCase()) { + throw new Error(`Signer mismatch: dApp requested ${requested}, wallet account is ${selected}`) + } +} + +/** Throws if the tx's declared chainId doesn't match the WC session chain. + * Same risk: tx.chainId override would let a dApp paired on chain X push + * a sign request for chain Y, leaking the session's signing intent across + * chains. We accept either decimal or 0x-prefixed hex per EIP-1898. */ +function assertChainIdMatches(txChainId: unknown, sessionChainId: number) { + if (txChainId === undefined || txChainId === null || txChainId === '') return + const raw = String(txChainId).trim() + const parsed = raw.startsWith('0x') ? parseInt(raw, 16) : parseInt(raw, 10) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid tx.chainId: ${txChainId}`) + } + if (parsed !== sessionChainId) { + throw new Error(`Chain mismatch: tx.chainId=${parsed} but WC session chain is ${sessionChainId}`) + } +} + +/** Versioned (v0+) Solana transactions cannot be parsed by current firmware, + * so they are signed via the message-signing path — i.e. blind-signed. We + * surface this in the approval method name so the UI can render a stronger + * warning. Returns false (treat as legacy) if the tx fails to parse, since + * legacy is the safer fallback (forces the firmware tx-parse path). */ +function isVersionedSolanaTx(transactionBase64: string): boolean { + try { + return parseSolanaTx(Buffer.from(transactionBase64, 'base64')).isVersioned + } catch { + return false + } +} const WC_PROJECT_ID = process.env.WALLETCONNECT_PROJECT_ID || '14d36ca1bc76a70273d44d384e8475ae' const WC_RELAY_URL = 'wss://relay.walletconnect.com' @@ -42,17 +89,67 @@ const CHAIN_RPC: Record = { } const SUPPORTED_CHAIN_IDS = new Set(Object.keys(CHAIN_RPC).map(Number)) -const SUPPORTED_CHAINS = Object.keys(CHAIN_RPC).map(id => `eip155:${id}`) +const SUPPORTED_EVM_CHAINS = Object.keys(CHAIN_RPC).map(id => `eip155:${id}`) + +// Cosmos namespace: amino-format signing only for now. signDirect requires +// proto decoding (cosmjs/proto-signing) and is intentionally NOT advertised +// — advertising methods we can't fulfill produces sessions that fail at +// request time. Handler still throws defensively in case a dApp asks anyway. +const SUPPORTED_COSMOS_METHODS = ['cosmos_getAccounts', 'cosmos_signAmino'] +const SUPPORTED_COSMOS_EVENTS: string[] = [] +// Only cosmoshub-4 in v1 — Osmosis/THOR/Maya use cosmos namespace too but have +// different bech32 prefixes and (in THOR/Maya's case) different hdwallet +// signers, so they need per-chain wiring. +const SUPPORTED_COSMOS_CHAINS = ['cosmos:cosmoshub-4'] + +// Solana namespace. getAccounts/requestAccounts are common post-pair calls +// from dApps even though WC pairs already include the account list. +const SUPPORTED_SOLANA_METHODS = [ + 'solana_getAccounts', + 'solana_requestAccounts', + 'solana_signMessage', + 'solana_signTransaction', + 'solana_signAndSendTransaction', +] +const SUPPORTED_SOLANA_EVENTS: string[] = [] +// CAIP-2 mainnet genesis hash. Some dApps use other CAIPs; iterate as needed. +const SUPPORTED_SOLANA_CHAINS = ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'] + +/** Information shown to the user when a session pair is proposed. */ +export interface WcPairApprovalInfo { + id: string + peerName: string + peerUrl: string + peerIcon: string + chains: string[] + methods: string[] +} export interface WcCallbacks { /** Get the current EVM address (checksummed) and its derivation index. Null if not ready. */ getEvmAddressInfo: () => { address: string; addressIndex: number } | null + /** Lazy-derive the EVM address if not yet initialized. Called from the + * proposal handler only when the dApp actually wants eip155 — Solana- or + * Cosmos-only proposals never trigger EVM derivation. */ + ensureEvmAddressInfo: () => Promise<{ address: string; addressIndex: number } | null> /** Sign an EVM transaction via the KeepKey device. */ ethSignTx: (params: any) => Promise /** Sign a message via the KeepKey device. */ ethSignMessage: (params: any) => Promise /** Sign typed data (EIP-712) via the KeepKey device. */ ethSignTypedData: (params: any) => Promise + /** Get the cosmos account info for a given CAIP chain id. Null if not ready. */ + getCosmosAccountInfo: (caipChain: string) => Promise<{ address: string; pubkeyBase64: string; addressNList: number[] } | null> + /** Sign an amino-format Cosmos StdSignDoc via the device. Returns base64 signature. */ + cosmosSignAmino: (params: { addressNList: number[]; signDoc: any }) => Promise<{ signatureBase64: string }> + /** Get the solana account (bs58-encoded ed25519 pubkey = address) for a CAIP chain. Null if not ready. */ + getSolanaAccountInfo: (caipChain: string) => Promise<{ address: string; addressNList: number[] } | null> + /** Sign a Solana message (raw bytes, base58 per WC spec). Returns 64-byte ed25519 signature. */ + solanaSignMessageRaw: (params: { addressNList: number[]; messageBase58: string }) => Promise<{ signatureBase64: string }> + /** Sign a Solana transaction (full base64 tx including empty sig slots). Returns assembled signed tx + signature. */ + solanaSignTransactionRaw: (params: { addressNList: number[]; signerAddress: string; transactionBase64: string }) => Promise<{ transactionBase64: string; signatureBase64: string }> + /** Broadcast a fully-signed serialized transaction via Pioneer. Returns the on-chain txid. */ + broadcastViaPioneer: (params: { networkId: string; serialized: string }) => Promise /** Show signing approval to user — returns true if approved. */ requestSigningApproval: (info: SigningRequestInfo) => Promise /** Dismiss the signing overlay. */ @@ -61,6 +158,10 @@ export interface WcCallbacks { log: (msg: string) => void /** Notify frontend of session changes. */ onSessionsChanged: (sessions: WcSessionInfo[]) => void + /** Surface a pending pair proposal to the user. */ + onPairApprovalRequest: (info: WcPairApprovalInfo) => void + /** Tell frontend the proposal is no longer pending (approved/rejected/timed out). */ + onPairApprovalDismiss: (id: string) => void } function sessionToInfo(session: SessionTypes.Struct): WcSessionInfo { @@ -78,11 +179,48 @@ export class WalletConnectManager { private web3wallet: InstanceType | null = null private callbacks: WcCallbacks private initPromise: Promise | null = null + private pendingPairApprovals = new Map void; timer: ReturnType }>() constructor(callbacks: WcCallbacks) { this.callbacks = callbacks } + /** Wait for the user to approve/reject a pending pair proposal. */ + private requestPairApproval(info: WcPairApprovalInfo, timeoutMs = 120_000): Promise { + if (this.pendingPairApprovals.size >= 10) return Promise.resolve(false) // sanity cap + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingPairApprovals.delete(info.id) + this.callbacks.onPairApprovalDismiss(info.id) + resolve(false) + }, timeoutMs) + this.pendingPairApprovals.set(info.id, { resolve, timer }) + this.callbacks.onPairApprovalRequest(info) + }) + } + + /** Frontend → backend: user clicked Approve in the pair-approval modal. */ + approvePair(id: string): boolean { + const entry = this.pendingPairApprovals.get(id) + if (!entry) return false + clearTimeout(entry.timer) + this.pendingPairApprovals.delete(id) + this.callbacks.onPairApprovalDismiss(id) + entry.resolve(true) + return true + } + + /** Frontend → backend: user clicked Reject in the pair-approval modal. */ + rejectPair(id: string): boolean { + const entry = this.pendingPairApprovals.get(id) + if (!entry) return false + clearTimeout(entry.timer) + this.pendingPairApprovals.delete(id) + this.callbacks.onPairApprovalDismiss(id) + entry.resolve(false) + return true + } + async init(): Promise { if (this.web3wallet) return // Coalesce concurrent init calls — second caller awaits the same promise @@ -135,6 +273,15 @@ export class WalletConnectManager { } async destroy(): Promise { + // Drain pending pair approvals so acquireWindowFocus refcount is balanced. + // If we skip this, disabling WC while a pair prompt is open leaks _alwaysOnTopRefs. + for (const [id, entry] of this.pendingPairApprovals) { + clearTimeout(entry.timer) + this.callbacks.onPairApprovalDismiss(id) + entry.resolve(false) + } + this.pendingPairApprovals.clear() + if (!this.web3wallet) return const sessions = this.web3wallet.getActiveSessions() for (const session of Object.values(sessions)) { @@ -162,35 +309,134 @@ export class WalletConnectManager { /** * Auto-approve session proposals. * The user explicitly initiated pairing (entered URI or deep link), - * so we approve with the vault's EVM address on SUPPORTED chains only. + * so we approve with the vault's address on SUPPORTED chains only. + * Advertises both eip155 (EVM) and cosmos namespaces. */ private onSessionProposal = async ( proposal: Web3WalletTypes.SessionProposal ) => { - const info = this.callbacks.getEvmAddressInfo() - if (!info) { - this.callbacks.log('[WC] Rejecting proposal — no device connected') - await this.web3wallet!.rejectSession({ - id: proposal.id, - reason: getSdkError('USER_REJECTED'), - }) + // Inspect the proposal up front so we only resolve accounts for the + // namespaces the dApp actually asks for. A required namespace must + // resolve; an optional namespace silently falls out if it doesn't. + const required = proposal.params.requiredNamespaces ?? {} + const optional = proposal.params.optionalNamespaces ?? {} + const namespaceRequired = (ns: string) => ns in required + const namespaceWanted = (ns: string) => ns in required || ns in optional + + let evmInfo: { address: string; addressIndex: number } | null = null + if (namespaceWanted('eip155')) { + // Lazy-derive on demand. Solana/Cosmos-only proposals never reach here. + try { + evmInfo = await this.callbacks.ensureEvmAddressInfo() + } catch (e: any) { + this.callbacks.log(`[WC] EVM address derivation failed: ${e.message}`) + } + if (!evmInfo && namespaceRequired('eip155')) { + this.callbacks.log('[WC] Rejecting proposal — eip155 required but no EVM address ready') + await this.web3wallet!.rejectSession({ id: proposal.id, reason: getSdkError('USER_REJECTED') }) + return + } + } + + const cosmosAccounts: string[] = [] + const cosmosChainsAdvertised: string[] = [] + if (namespaceWanted('cosmos')) { + for (const chain of SUPPORTED_COSMOS_CHAINS) { + try { + const info = await this.callbacks.getCosmosAccountInfo(chain) + if (info) { + cosmosAccounts.push(`${chain}:${info.address}`) + cosmosChainsAdvertised.push(chain) + } + } catch (e: any) { + this.callbacks.log(`[WC] Cosmos account fetch failed for ${chain}: ${e.message}`) + } + } + if (cosmosChainsAdvertised.length === 0 && namespaceRequired('cosmos')) { + this.callbacks.log('[WC] Rejecting proposal — cosmos required but no cosmos account ready') + await this.web3wallet!.rejectSession({ id: proposal.id, reason: getSdkError('USER_REJECTED') }) + return + } + } + + const solanaAccounts: string[] = [] + const solanaChainsAdvertised: string[] = [] + if (namespaceWanted('solana')) { + for (const chain of SUPPORTED_SOLANA_CHAINS) { + try { + const info = await this.callbacks.getSolanaAccountInfo(chain) + if (info) { + solanaAccounts.push(`${chain}:${info.address}`) + solanaChainsAdvertised.push(chain) + } + } catch (e: any) { + this.callbacks.log(`[WC] Solana account fetch failed for ${chain}: ${e.message}`) + } + } + if (solanaChainsAdvertised.length === 0 && namespaceRequired('solana')) { + this.callbacks.log('[WC] Rejecting proposal — solana required but no solana account ready') + await this.web3wallet!.rejectSession({ id: proposal.id, reason: getSdkError('USER_REJECTED') }) + return + } + } + + // Ask the user to approve the pair before establishing the session. + const meta = proposal.params.proposer.metadata + const proposedChains = Object.values(proposal.params.requiredNamespaces ?? {}) + .flatMap(ns => ns.chains ?? []) + .concat(Object.values(proposal.params.optionalNamespaces ?? {}).flatMap(ns => ns.chains ?? [])) + const proposedMethods = Object.values(proposal.params.requiredNamespaces ?? {}) + .flatMap(ns => ns.methods ?? []) + const approved = await this.requestPairApproval({ + id: String(proposal.id), + peerName: meta.name ?? 'Unknown dApp', + peerUrl: meta.url ?? '', + peerIcon: meta.icons?.[0] ?? '', + chains: Array.from(new Set(proposedChains)), + methods: Array.from(new Set(proposedMethods)), + }) + if (!approved) { + this.callbacks.log(`[WC] User rejected pair: ${meta.name}`) + try { + await this.web3wallet!.rejectSession({ + id: proposal.id, + reason: getSdkError('USER_REJECTED'), + }) + } catch { /* best effort */ } return } try { - // Only approve chains we actually have RPC for — never approve unsupported chains - const accounts = SUPPORTED_CHAINS.map(chain => `${chain}:${info.address}`) + const supportedNamespaces: Parameters[0]['supportedNamespaces'] = {} + if (evmInfo) { + const evmAccounts = SUPPORTED_EVM_CHAINS.map(c => `${c}:${evmInfo!.address}`) + supportedNamespaces.eip155 = { + chains: SUPPORTED_EVM_CHAINS, + methods: SUPPORTED_METHODS, + events: SUPPORTED_EVENTS, + accounts: evmAccounts, + } + } + if (cosmosChainsAdvertised.length > 0) { + supportedNamespaces.cosmos = { + chains: cosmosChainsAdvertised, + methods: SUPPORTED_COSMOS_METHODS, + events: SUPPORTED_COSMOS_EVENTS, + accounts: cosmosAccounts, + } + } + if (solanaChainsAdvertised.length > 0) { + supportedNamespaces.solana = { + chains: solanaChainsAdvertised, + methods: SUPPORTED_SOLANA_METHODS, + events: SUPPORTED_SOLANA_EVENTS, + accounts: solanaAccounts, + } + } const namespaces = buildApprovedNamespaces({ proposal: proposal.params, - supportedNamespaces: { - eip155: { - chains: SUPPORTED_CHAINS, - methods: SUPPORTED_METHODS, - events: SUPPORTED_EVENTS, - accounts, - }, - }, + supportedNamespaces, }) await this.web3wallet!.approveSession({ @@ -248,10 +494,20 @@ export class WalletConnectManager { private async handleRequest( method: string, - params: any[], + params: any, chainId: string, appName: string, ): Promise { + // Cosmos namespace + if (chainId.startsWith('cosmos:')) { + return this.handleCosmosRequest(method, params, chainId, appName) + } + // Solana namespace + if (chainId.startsWith('solana:')) { + return this.handleSolanaRequest(method, params, chainId, appName) + } + + // EVM namespace const info = this.callbacks.getEvmAddressInfo() if (!info) throw new Error('No device connected') @@ -261,12 +517,15 @@ export class WalletConnectManager { } const addressNList = evmAddressPath(info.addressIndex) + const evmParams = params as any[] switch (method) { case 'personal_sign': case 'eth_sign': { // personal_sign: [message, address], eth_sign: [address, message] - const message = method === 'personal_sign' ? params[0] : params[1] + const message = method === 'personal_sign' ? evmParams[0] : evmParams[1] + const requestedAddress = method === 'personal_sign' ? evmParams[1] : evmParams[0] + assertSignerMatches(requestedAddress, info.address) return this.signMessage(message, addressNList, appName, chainIdNum) } @@ -274,13 +533,16 @@ export class WalletConnectManager { case 'eth_signTypedData_v3': case 'eth_signTypedData_v4': { // params: [address, typedDataJSON] - const typedData = typeof params[1] === 'string' ? JSON.parse(params[1]) : params[1] + assertSignerMatches(evmParams[0], info.address) + const typedData = typeof evmParams[1] === 'string' ? JSON.parse(evmParams[1]) : evmParams[1] return this.signTypedData(typedData, addressNList, appName, chainIdNum) } case 'eth_sendTransaction': case 'eth_signTransaction': { - const tx = params[0] + const tx = evmParams[0] + assertSignerMatches(tx?.from, info.address) + assertChainIdMatches(tx?.chainId, chainIdNum) return this.signTransaction(tx, addressNList, appName, chainIdNum, method === 'eth_sendTransaction') } @@ -289,6 +551,204 @@ export class WalletConnectManager { } } + private async handleCosmosRequest( + method: string, + params: any, + chainId: string, + appName: string, + ): Promise { + if (!SUPPORTED_COSMOS_CHAINS.includes(chainId)) { + throw new Error(`Unsupported cosmos chain: ${chainId}`) + } + const account = await this.callbacks.getCosmosAccountInfo(chainId) + if (!account) throw new Error('Cosmos account not available') + + switch (method) { + case 'cosmos_getAccounts': { + return [{ + address: account.address, + algo: 'secp256k1', + pubkey: account.pubkeyBase64, + }] + } + + case 'cosmos_signAmino': { + // WC params: { signerAddress, signDoc } + // signDoc is the legacy StdSignDoc: { chain_id, account_number, sequence, fee, msgs, memo } + const { signerAddress, signDoc } = params as { signerAddress: string; signDoc: any } + if (signerAddress && signerAddress.toLowerCase() !== account.address.toLowerCase()) { + throw new Error(`Signer address mismatch: dApp asked ${signerAddress}, wallet is ${account.address}`) + } + const signingId = crypto.randomUUID() + const signingInfo: SigningRequestInfo = { + id: signingId, + method: '/cosmos/sign-amino', + appName, + chain: 'cosmos', + from: account.address, + chainId: 0, // not an EVM numeric id + data: JSON.stringify(signDoc), + } + const approved = await this.callbacks.requestSigningApproval(signingInfo) + if (!approved) throw new Error('User rejected signing') + try { + const { signatureBase64 } = await this.callbacks.cosmosSignAmino({ + addressNList: account.addressNList, + signDoc, + }) + return { + signed: signDoc, + signature: { + pub_key: { type: 'tendermint/PubKeySecp256k1', value: account.pubkeyBase64 }, + signature: signatureBase64, + }, + } + } finally { + this.callbacks.dismissSigning(signingId) + } + } + + case 'cosmos_signDirect': { + // SignDoc is proto-encoded — would need cosmjs/proto-signing or a hand-rolled + // protobuf decoder. Deferred. Most cosmos dApps fall back to amino if signDirect + // is unavailable on the wallet side. + throw new Error('cosmos_signDirect is not yet supported — please use cosmos_signAmino') + } + + default: + throw new Error(`Unsupported cosmos method: ${method}`) + } + } + + private async handleSolanaRequest( + method: string, + params: any, + chainId: string, + appName: string, + ): Promise { + if (!SUPPORTED_SOLANA_CHAINS.includes(chainId)) { + throw new Error(`Unsupported solana chain: ${chainId}`) + } + const account = await this.callbacks.getSolanaAccountInfo(chainId) + if (!account) throw new Error('Solana account not available') + + switch (method) { + case 'solana_getAccounts': + case 'solana_requestAccounts': { + // Return the bs58-encoded pubkey. Some dApps prefer a list of accounts; + // we have a single deterministic account per chain. + return [{ pubkey: account.address }] + } + + case 'solana_signMessage': { + // WC params: { pubkey: bs58, message: bs58 } per CAIP-122 / WC Solana docs. + const { pubkey, message } = params as { pubkey: string; message: string } + if (pubkey && pubkey !== account.address) { + throw new Error(`Pubkey mismatch: dApp asked ${pubkey}, wallet is ${account.address}`) + } + const signingId = crypto.randomUUID() + const signingInfo: SigningRequestInfo = { + id: signingId, + method: '/solana/sign-message', + appName, + chain: 'solana', + from: account.address, + chainId: 0, + data: message, + needsBlindSigning: true, + requiresAdvancedMode: true, + solanaMessageDecoded: buildSolanaMessageDecodedInfo(message, { + encoding: 'base58', + signer: account.address, + }), + } + const approved = await this.callbacks.requestSigningApproval(signingInfo) + if (!approved) throw new Error('User rejected signing') + try { + const { signatureBase64 } = await this.callbacks.solanaSignMessageRaw({ + addressNList: account.addressNList, + messageBase58: message, + }) + // WC dApps expect the signature as bs58 (Solana standard). + return { signature: base64ToBase58(signatureBase64) } + } finally { + this.callbacks.dismissSigning(signingId) + } + } + + case 'solana_signTransaction': { + // WC params: { transaction: base64 } + const { transaction } = params as { transaction: string } + const signingId = crypto.randomUUID() + const signingInfo: SigningRequestInfo = { + id: signingId, + // v0+ versioned txs hit the message-signing path on the device — the + // firmware can't display program/account details, so this is blind. + method: isVersionedSolanaTx(transaction) + ? '/solana/sign-transaction-blind' + : '/solana/sign-transaction', + appName, + chain: 'solana', + from: account.address, + chainId: 0, + data: transaction, + } + const approved = await this.callbacks.requestSigningApproval(signingInfo) + if (!approved) throw new Error('User rejected signing') + try { + const { transactionBase64, signatureBase64 } = await this.callbacks.solanaSignTransactionRaw({ + addressNList: account.addressNList, + signerAddress: account.address, + transactionBase64: transaction, + }) + return { + signature: base64ToBase58(signatureBase64), + transaction: transactionBase64, + } + } finally { + this.callbacks.dismissSigning(signingId) + } + } + + case 'solana_signAndSendTransaction': { + // WC params: { transaction: base64 } — same as signTransaction, plus broadcast. + const { transaction } = params as { transaction: string } + const signingId = crypto.randomUUID() + const signingInfo: SigningRequestInfo = { + id: signingId, + method: isVersionedSolanaTx(transaction) + ? '/solana/sign-and-send-blind' + : '/solana/sign-and-send', + appName, + chain: 'solana', + from: account.address, + chainId: 0, + data: transaction, + } + const approved = await this.callbacks.requestSigningApproval(signingInfo) + if (!approved) throw new Error('User rejected signing') + try { + const { transactionBase64 } = await this.callbacks.solanaSignTransactionRaw({ + addressNList: account.addressNList, + signerAddress: account.address, + transactionBase64: transaction, + }) + // Pioneer broadcasts the assembled signed tx. networkId == the CAIP-2 chain. + const txid = await this.callbacks.broadcastViaPioneer({ + networkId: chainId, + serialized: transactionBase64, + }) + return { signature: txid } + } finally { + this.callbacks.dismissSigning(signingId) + } + } + + default: + throw new Error(`Unsupported solana method: ${method}`) + } + } + // ── JSON-RPC helper ───────────────────────────────────────────── private async rpcCall(chainId: number, method: string, params: any[]): Promise { diff --git a/projects/keepkey-vault/src/bun/zcash-sidecar.ts b/projects/keepkey-vault/src/bun/zcash-sidecar.ts index f9f853ee..850aaaa4 100644 --- a/projects/keepkey-vault/src/bun/zcash-sidecar.ts +++ b/projects/keepkey-vault/src/bun/zcash-sidecar.ts @@ -218,24 +218,87 @@ export async function sendCommand(cmd: string, params: Record = {}, /** * Stop the sidecar process. + * + * Clears every piece of cached state alongside the process so a subsequent + * `startSidecar()` boots into a clean slate. Without this, a privacy-disable + * → enable cycle (or an emulator wipe → reseed where the new device has a + * different FVK) would leave `cachedAddress` / `cachedFvk` / `cachedSyncedTo` + * pointing at the old wallet's state — `hasFvkLoaded()` would return true, + * `getShieldedBalance()` would short-circuit, and the dashboard would render + * stale numbers for whoever the new device belongs to. */ export function stopSidecar(): void { ready = false - if (sidecarProc) { + const procToKill = sidecarProc + sidecarProc = null + if (procToKill) { + // Reject every in-flight request bound to this proc up-front. The new + // sidecar (if started after this stop) gets a fresh `pendingRequests` + // map and isn't aware of these req_ids, so leaving them in place would + // hang callers until their per-request timeout fires. + for (const [, pending] of pendingRequests) { + clearTimeout(pending.timeout) + pending.reject(new Error("Sidecar stopped")) + } + pendingRequests.clear() + try { // Send quit command gracefully - sidecarProc.stdin.write('{"cmd":"quit"}\n') - sidecarProc.stdin.flush() + procToKill.stdin.write('{"cmd":"quit"}\n') + procToKill.stdin.flush() } catch { /* already dead */ } - // Force kill after 2s + // Force kill after 2s. Capture the local proc reference so a + // stop → start cycle within 2 seconds doesn't kill the new sidecar + // when the old timeout fires. setTimeout(() => { - try { sidecarProc?.kill() } catch { /* already dead */ } - sidecarProc = null + try { procToKill.kill() } catch { /* already dead */ } }, 2000) console.log("[zcash-sidecar] Stopping") } + // Always clear cached state, even if the process was already gone. + cachedAddress = null + cachedFvk = null + cachedSyncedTo = null + cachedReleaseBlock = null +} + +/** + * Delete the sidecar's on-disk wallet database (~/.keepkey/zcash_wallet.db). + * + * The sidecar persists the FVK + scanned notes between sessions and auto-loads + * them on startup. After a seed change (different mnemonic, passphrase change, + * or hidden-wallet activation) the persisted FVK belongs to the wrong wallet, + * and the auto-load would re-populate the in-process cache with stale state + * even after `stopSidecar()` clears it. Wipe the DB so the next start boots + * with no FVK and `ensureFvkLoaded()` re-derives from the device. + * + * Caller must `stopSidecar()` first — the file is locked while the sidecar + * holds it open. + */ +export function wipeSidecarWalletDb(): void { + try { + const home = process.env.HOME || process.env.USERPROFILE + if (!home) { + console.warn("[zcash-sidecar] Cannot wipe wallet DB: HOME / USERPROFILE not set") + return + } + const dbPath = `${home}/.keepkey/zcash_wallet.db` + const f = Bun.file(dbPath) + if (f.size > 0) { + // Use the synchronous filesystem API. unlinkSync would be ideal but + // keep the dep on bun's standard runtime by routing through node:fs. + // (Bun.file has no remove method; use require for consistency with + // the rest of this file.) + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require("node:fs") + fs.unlinkSync(dbPath) + console.log(`[zcash-sidecar] Wiped wallet DB at ${dbPath}`) + } + } catch (e: any) { + console.warn(`[zcash-sidecar] Failed to wipe wallet DB (non-fatal): ${e?.message || e}`) + } } /** @@ -430,15 +493,24 @@ function readStdout(proc: Subprocess<"pipe", "pipe", "pipe">): void { } try { reader.releaseLock() } catch { /* already released */ } - // Process exited — reject any pending requests - for (const [, pending] of pendingRequests) { - clearTimeout(pending.timeout) - pending.reject(new Error("Sidecar process exited")) + // Process exited. Only mutate global state if THIS proc is still the + // current one — `stopSidecar()` may have already detached us (new proc + // started in the meantime), in which case it has its own + // `pendingRequests` ownership and `ready` flag tied to the new proc. + // Touching them here would clear the new sidecar's in-flight requests + // and silently mark it not-ready. + if (sidecarProc === proc) { + for (const [, pending] of pendingRequests) { + clearTimeout(pending.timeout) + pending.reject(new Error("Sidecar process exited")) + } + pendingRequests.clear() + ready = false + sidecarProc = null + console.log("[zcash-sidecar] Process exited") + } else { + console.log("[zcash-sidecar] Old process exited (already detached)") } - pendingRequests.clear() - ready = false - sidecarProc = null - console.log("[zcash-sidecar] Process exited") })() } diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index ff972a71..a3382f84 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -15,6 +15,7 @@ import { FirmwareDropZone } from "./components/FirmwareDropZone" import { SplashScreen } from "./components/SplashScreen" import { DeviceGrid } from "./components/DeviceGrid" import { DeviceClaimedDialog } from "./components/DeviceClaimedDialog" +import { LinuxUdevWarning } from "./components/LinuxUdevWarning" import { OobSetupWizard } from "./components/OobSetupWizard" import { TopNav, SplashNav } from "./components/TopNav" import { WindowResizeHandles } from "./components/WindowResizeHandles" @@ -28,9 +29,14 @@ import { useUpdateState } from "./hooks/useUpdateState" import { rpcRequest, onRpcMessage } from "./lib/rpc" import { Z } from "./lib/z-index" import { ActivityTracker } from "./components/ActivityTracker" +import { SwapRpcMount } from "./components/SwapRpcMount" +import { NAV_CONTENT_OFFSET, NAV_CONTENT_OFFSET_WITH_BANNER } from "./layout" import type { PinRequestType, PairingRequestInfo, SigningRequestInfo, ApiLogEntry, AppSettings, EmulatorStatus } from "../shared/types" type AppPhase = "splash" | "claimed" | "setup" | "ready" +type SigningPhase = "approve" | "sending-payload" | "device-confirm" + +const SIGNING_PAYLOAD_MIN_MS = 15000 function App() { const { t } = useTranslation() @@ -75,11 +81,50 @@ function App() { rpcRequest<{ version: string; channel: string }>("getAppVersion") .then(setAppVersion) .catch(() => {}) - rpcRequest("getAppSettings") - .then((s) => { setRestApiEnabled(s.restApiEnabled); setWalletConnectEnabled(s.walletConnectEnabled); setSwapsEnabled(s.swapsEnabled); setEmulatorEnabled(s.emulatorEnabled) }) - .catch(() => {}) + const refreshSettings = () => { + rpcRequest("getAppSettings") + .then((s) => { setRestApiEnabled(s.restApiEnabled); setWalletConnectEnabled(s.walletConnectEnabled); setSwapsEnabled(s.swapsEnabled); setEmulatorEnabled(s.emulatorEnabled) }) + .catch(() => {}) + } + refreshSettings() + // Other surfaces (settings drawer close, drop-zone dylib install) flip + // server-side flags then dispatch this event so the React tree picks up + // the new state without waiting for the user to reopen settings. + window.addEventListener("keepkey-settings-changed", refreshSettings) + return () => window.removeEventListener("keepkey-settings-changed", refreshSettings) }, []) + // ── REST API UI-active handshake ───────────────────────────────── + // The Bun process refuses to serve pubkeys/addresses on port 1646 unless + // the Vault UI signals it's open + heartbeats regularly. `viewDeviceId` + // scopes serving to the device the user currently has open, so a + // 3rd-party request can never get xpubs from a device the user isn't + // actively viewing (incl. watch-only mode which uses the cached device). + // + // Defer activation until we know which device the UI is bound to. On + // fresh mount `deviceState.deviceId` is null until the engine state + // machine reports `ready`; activating with viewDeviceId=null in that + // window would let a stale on-disk pubkey cache from a prior session + // be served against the wrong (or no) device, since requireUiActive + // only enforces device matching when uiState.viewDeviceId is truthy. + useEffect(() => { + const viewDeviceId = watchOnlyMode ? (watchOnlyDeviceId ?? null) : (deviceState.deviceId ?? null) + if (!viewDeviceId) return + rpcRequest("uiSetActive", { active: true, viewDeviceId }).catch(() => {}) + const heartbeat = setInterval(() => { + rpcRequest("uiHeartbeat", { viewDeviceId }).catch(() => {}) + }, 15_000) + const beforeUnload = () => { + try { rpcRequest("uiSetActive", { active: false, viewDeviceId: null }).catch(() => {}) } catch { /* ignore */ } + } + window.addEventListener("beforeunload", beforeUnload) + return () => { + clearInterval(heartbeat) + window.removeEventListener("beforeunload", beforeUnload) + rpcRequest("uiSetActive", { active: false, viewDeviceId: null }).catch(() => {}) + } + }, [deviceState.deviceId, watchOnlyMode, watchOnlyDeviceId]) + // Reset dismiss when update phase transitions to available or ready useEffect(() => { if (update.phase === "available" || update.phase === "ready") { @@ -198,41 +243,95 @@ function App() { // ── Signing approval overlay ──────────────────────────────────── const [signingRequest, setSigningRequest] = useState(null) - const [signingPhase, setSigningPhase] = useState<'approve' | 'device-confirm'>('approve') + const [signingPhase, setSigningPhase] = useState('approve') + const signingRequestRef = useRef(null) + const signingPhaseRef = useRef('approve') + const signingPayloadStartedAt = useRef(null) + const signingConfirmTimer = useRef | null>(null) + + const setSigningPhaseTracked = useCallback((phase: SigningPhase) => { + signingPhaseRef.current = phase + setSigningPhase(phase) + }, []) + + const clearSigningConfirmTimer = useCallback(() => { + if (signingConfirmTimer.current) { + clearTimeout(signingConfirmTimer.current) + signingConfirmTimer.current = null + } + }, []) useEffect(() => { const unsub1 = onRpcMessage("signing-request", (payload) => { - setSigningPhase('approve') - setSigningRequest(payload as SigningRequestInfo) + clearSigningConfirmTimer() + signingPayloadStartedAt.current = null + const request = payload as SigningRequestInfo + signingRequestRef.current = request + setSigningPhaseTracked('approve') + setSigningRequest(request) }) const unsub2 = onRpcMessage("signing-dismissed", () => { + clearSigningConfirmTimer() + signingPayloadStartedAt.current = null + signingRequestRef.current = null setSigningRequest(null) - setSigningPhase('approve') + setSigningPhaseTracked('approve') }) return () => { unsub1(); unsub2() } - }, []) + }, [clearSigningConfirmTimer, setSigningPhaseTracked]) + + useEffect(() => { + return () => clearSigningConfirmTimer() + }, [clearSigningConfirmTimer]) + + useEffect(() => { + return onRpcMessage("device-button-request", () => { + if (!signingRequestRef.current || signingPhaseRef.current !== 'sending-payload') return + + const startedAt = signingPayloadStartedAt.current ?? Date.now() + const elapsedMs = Date.now() - startedAt + const delayMs = Math.max(0, SIGNING_PAYLOAD_MIN_MS - elapsedMs) + + clearSigningConfirmTimer() + signingConfirmTimer.current = setTimeout(() => { + signingConfirmTimer.current = null + if (signingPhaseRef.current === 'sending-payload') { + setSigningPhaseTracked('device-confirm') + } + }, delayMs) + }) + }, [clearSigningConfirmTimer, setSigningPhaseTracked]) const handleApproveSign = useCallback(async () => { if (!signingRequest) return - // Transition overlay to "confirm on device" — don't dismiss yet - setSigningPhase('device-confirm') + clearSigningConfirmTimer() + signingPayloadStartedAt.current = Date.now() + // The backend is now unblocked and can start writing the request to the + // device. Wait for the real device ButtonRequest before asking the user + // to press the physical button. + setSigningPhaseTracked('sending-payload') try { await rpcRequest("approveSigningRequest", { id: signingRequest.id }) } catch (e) { console.error("approveSign:", e) + clearSigningConfirmTimer() + signingPayloadStartedAt.current = null // RPC failed (device disconnected, timeout, etc.) — revert to actionable // approve/reject state so the user isn't stuck on a dead "confirm on device" overlay. - setSigningPhase('approve') + setSigningPhaseTracked('approve') } // On success, overlay stays open until 'signing-dismissed' RPC arrives from bun side - }, [signingRequest]) + }, [clearSigningConfirmTimer, setSigningPhaseTracked, signingRequest]) const handleRejectSign = useCallback(async () => { if (!signingRequest) return try { await rpcRequest("rejectSigningRequest", { id: signingRequest.id }) } catch (e) { console.error("rejectSign:", e) } + clearSigningConfirmTimer() + signingPayloadStartedAt.current = null + signingRequestRef.current = null setSigningRequest(null) - setSigningPhase('approve') - }, [signingRequest]) + setSigningPhaseTracked('approve') + }, [clearSigningConfirmTimer, setSigningPhaseTracked, signingRequest]) // ── Paired Apps panel ─────────────────────────────────────────── const [pairedAppsOpen, setPairedAppsOpen] = useState(false) @@ -274,6 +373,34 @@ function App() { }) }, []) + // Warm-path WC deep link: backend hands us the URI so the panel can mount + // *before* the session_proposal arrives — the pair-approval modal lives + // inside the panel, so opening it after the proposal would let the modal + // render invisibly and silently time out at 120s. + useEffect(() => { + return onRpcMessage("wc-deep-link-pair", (data) => { + const { uri } = data as { uri: string } + if (!walletConnectEnabled) { + setWcNotSupportedOpen(true) + return + } + setWcUri(uri) + setWcPanelOpen(true) + }) + }, [walletConnectEnabled]) + + // Force-open the panel whenever a pair proposal arrives. The pair-approval + // modal lives inside WalletConnectPanel and renders nothing while the panel + // is closed — so a proposal landing after the user closed the panel (e.g. + // closed it between hitting Pair and the session_proposal arriving) would + // be invisible until the 120s backend timeout. The panel keeps its own + // listener for the request payload; this one only ensures visibility. + useEffect(() => { + return onRpcMessage("wc-pair-request", () => { + setWcPanelOpen(true) + }) + }, []) + // ── Check for pending deep link from cold start ───────────────── useEffect(() => { rpcRequest("getPendingDeepLink").then(uri => { @@ -593,7 +720,7 @@ function App() { watchOnly onExitToDeviceSelect={() => { setWatchOnlyMode(false); setWatchOnlyDeviceId(undefined) }} /> - + {}} /> @@ -627,27 +754,33 @@ function App() { const isError = deviceState.state === "error" const needsPin = deviceState.state === "needs_pin" const needsPassphrase = deviceState.state === "needs_passphrase" + const linuxUdevBlocked = !!deviceState.linuxUdevPermissionDenied return ( <>{splashNav}{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} { rpcRequest("retryConnect").catch(() => {}) }} + variant={linuxUdevBlocked ? "error" : needsPin || needsPassphrase || isConnecting ? "connecting" : isError ? "error" : "searching"} + childrenReady={linuxUdevBlocked ? true : gridReady} + onLogoClick={linuxUdevBlocked || needsPin || needsPassphrase ? undefined : () => { rpcRequest("retryConnect").catch(() => {}) }} > - {/* Unified device grid — registered devices + emulator wallets */} - { setWatchOnlyDeviceId(id); setWatchOnlyLabel(label); setWatchOnlyMode(true) }} - onReady={() => setGridReady(true)} - emulatorEnabled={emulatorEnabled} - /> + {linuxUdevBlocked ? ( + + ) : ( + /* Unified device grid — registered devices + emulator wallets */ + { setWatchOnlyDeviceId(id); setWatchOnlyLabel(label); setWatchOnlyMode(true) }} + onReady={() => setGridReady(true)} + emulatorEnabled={emulatorEnabled} + /> + )} ) @@ -683,17 +816,19 @@ function App() { isEmulator={deviceState.isEmulator} onSettingsToggle={() => setSettingsOpen((o) => !o)} onMobileToggle={() => setMobilePanelOpen((o) => !o)} + onWalletConnectToggle={walletConnectEnabled ? handleOpenWalletConnect : undefined} settingsOpen={settingsOpen} mobileOpen={mobilePanelOpen} + walletConnectOpen={wcPanelOpen} activeTab={activeTab} onTabChange={handleTabChange} passphraseActive={deviceState.isHiddenWallet} onExitToDeviceSelect={deviceState.isEmulator ? () => { rpcRequest("emulatorStop").catch(() => {}) } : undefined} /> - - {/* pt: 54px TopNav + 50px banner height when visible */} + + {/* TopNav offset plus banner height when visible. */} {activeTab === "vault" && setSettingsOpen(true)} firmwareVersion={deviceState.firmwareVersion} forceRefresh={wizardComplete} onForceRefreshConsumed={() => setWizardComplete(false)} isHiddenWallet={deviceState.isHiddenWallet} />} - {activeTab === "apps" && } + {activeTab === "apps" && } + {/* Top-level swap dialog mount for REST-driven /api/v2/swap/open. */} + {/* Enable API Bridge dialog — shown when user tries to launch an app with REST disabled */} {/* ── WalletConnect Not Supported dialog ──────────────────── */} {wcNotSupportedOpen && ( diff --git a/projects/keepkey-vault/src/mainview/assets/providers/0x.png b/projects/keepkey-vault/src/mainview/assets/providers/0x.png new file mode 100644 index 00000000..c094cc38 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/0x.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/1inch.png b/projects/keepkey-vault/src/mainview/assets/providers/1inch.png new file mode 100644 index 00000000..b873e6c4 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/1inch.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/README.md b/projects/keepkey-vault/src/mainview/assets/providers/README.md new file mode 100644 index 00000000..f601c014 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/README.md @@ -0,0 +1,39 @@ +# Swap provider badges + +Used by `` (`src/mainview/components/ProviderBadge.tsx`) to identify +the routing provider in quote, pre-sign, in-flight, and history surfaces. + +All marks shown are property of their respective owners. Used here for partner +attribution / nominative identification of the actual service routing the swap. + +| File | Source | Format | +|---|---|---| +| `thorchain.png` | pioneers.dev | PNG, downsampled to 64px | +| `mayachain.png` | pioneers.dev | PNG, downsampled to 64px | +| `0x.png` | pioneers.dev | PNG, downsampled to 64px | +| `uniswap.png` | pioneers.dev | PNG, downsampled to 64px | +| `1inch.png` | pioneers.dev | PNG, downsampled to 64px | +| `cow.png` | pioneers.dev | PNG, downsampled to 64px | +| `balancer.png` | pioneers.dev | PNG, downsampled to 64px | +| `sushi.png` | pioneers.dev | PNG, downsampled to 64px | +| `relay.svg` | monogram fallback | SVG, brand color + letter | +| `shapeshift.svg` | monogram fallback | SVG, brand color + letter | +| `lifi.svg` | monogram fallback | SVG, brand color + letters | +| `chainflip.svg` | monogram fallback | SVG, brand color + letter | +| `across.svg` | monogram fallback | SVG, brand color + letter | +| `curve.svg` | monogram fallback | SVG, brand color + letter | + +## Replacing a monogram with an official mark + +To swap in an officially-licensed brand asset, drop the file at the same path +(matching name + extension if SVG, or update the import in `ProviderBadge.tsx`). +Keep PNGs at 64×64 max — badges render at 12–40 px so anything larger is bloat. + +## Optimization + +```bash +magick in.png -resize 64x64 -strip out.tmp.png +pngquant --force --quality 65-85 --speed 1 --output out.png out.tmp.png +``` + +Target <8 KB per file; current set is all <3 KB. diff --git a/projects/keepkey-vault/src/mainview/assets/providers/across.svg b/projects/keepkey-vault/src/mainview/assets/providers/across.svg new file mode 100644 index 00000000..8ef01433 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/across.svg @@ -0,0 +1 @@ +A diff --git a/projects/keepkey-vault/src/mainview/assets/providers/animations/relay.gif b/projects/keepkey-vault/src/mainview/assets/providers/animations/relay.gif new file mode 100644 index 00000000..ab1ea4d0 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/animations/relay.gif differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/balancer.png b/projects/keepkey-vault/src/mainview/assets/providers/balancer.png new file mode 100644 index 00000000..44686d56 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/balancer.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/chainflip.svg b/projects/keepkey-vault/src/mainview/assets/providers/chainflip.svg new file mode 100644 index 00000000..4b2572da --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/chainflip.svg @@ -0,0 +1 @@ +C diff --git a/projects/keepkey-vault/src/mainview/assets/providers/cow.png b/projects/keepkey-vault/src/mainview/assets/providers/cow.png new file mode 100644 index 00000000..ce0fd70b Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/cow.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/curve.svg b/projects/keepkey-vault/src/mainview/assets/providers/curve.svg new file mode 100644 index 00000000..e2c1739e --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/curve.svg @@ -0,0 +1 @@ +C diff --git a/projects/keepkey-vault/src/mainview/assets/providers/lifi.svg b/projects/keepkey-vault/src/mainview/assets/providers/lifi.svg new file mode 100644 index 00000000..57dbb5e8 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/lifi.svg @@ -0,0 +1 @@ +LI diff --git a/projects/keepkey-vault/src/mainview/assets/providers/mayachain.png b/projects/keepkey-vault/src/mainview/assets/providers/mayachain.png new file mode 100644 index 00000000..a8e6e77d Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/mayachain.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/relay.svg b/projects/keepkey-vault/src/mainview/assets/providers/relay.svg new file mode 100644 index 00000000..5b046f31 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/relay.svg @@ -0,0 +1 @@ +R diff --git a/projects/keepkey-vault/src/mainview/assets/providers/shapeshift.svg b/projects/keepkey-vault/src/mainview/assets/providers/shapeshift.svg new file mode 100644 index 00000000..2d71e09f --- /dev/null +++ b/projects/keepkey-vault/src/mainview/assets/providers/shapeshift.svg @@ -0,0 +1 @@ +S diff --git a/projects/keepkey-vault/src/mainview/assets/providers/sushi.png b/projects/keepkey-vault/src/mainview/assets/providers/sushi.png new file mode 100644 index 00000000..956f174e Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/sushi.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/thorchain.png b/projects/keepkey-vault/src/mainview/assets/providers/thorchain.png new file mode 100644 index 00000000..cfd56fbc Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/thorchain.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/providers/uniswap.png b/projects/keepkey-vault/src/mainview/assets/providers/uniswap.png new file mode 100644 index 00000000..34b97a72 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/providers/uniswap.png differ diff --git a/projects/keepkey-vault/src/mainview/assets/swap/calculating.gif b/projects/keepkey-vault/src/mainview/assets/swap/calculating.gif new file mode 100644 index 00000000..69680ecb Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/swap/calculating.gif differ diff --git a/projects/keepkey-vault/src/mainview/assets/swap/completed.gif b/projects/keepkey-vault/src/mainview/assets/swap/completed.gif new file mode 100644 index 00000000..0bf1d3b3 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/swap/completed.gif differ diff --git a/projects/keepkey-vault/src/mainview/assets/swap/kk.gif b/projects/keepkey-vault/src/mainview/assets/swap/kk.gif new file mode 100644 index 00000000..97ead5ff Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/swap/kk.gif differ diff --git a/projects/keepkey-vault/src/mainview/assets/swap/shifting.gif b/projects/keepkey-vault/src/mainview/assets/swap/shifting.gif new file mode 100644 index 00000000..a071b543 Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/swap/shifting.gif differ diff --git a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx index d10840a0..70edfff9 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx @@ -36,7 +36,7 @@ const TYPE_CONFIG: Record = { const STATUS_CONFIG: Record = { broadcast: { label: 'Broadcast', color: '#23DCC8' }, signed: { label: 'Signed', color: '#F7931A' }, - completed: { label: 'Completed', color: '#4ADE80' }, + completed: { label: 'Completed', color: 'var(--teal)' }, refunded: { label: 'Refunded', color: '#FB923C' }, failed: { label: 'Failed', color: '#E53E3E' }, } @@ -47,8 +47,8 @@ const SWAP_STATUS_CONFIG: Record(items: T[]): T[] { + return [...items].sort((a, b) => recentTimestamp(b) - recentTimestamp(a)) +} + +const USD_FORMATTER = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +function formatUsd(value: number | null | undefined): string | null { + if (!Number.isFinite(value ?? NaN)) return null + const n = Number(value) + if (n > 0 && n < 0.01) return '<$0.01' + return USD_FORMATTER.format(n) +} + +function commifyInteger(value: string): string { + return value.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +function formatBaseUnits(raw: string | undefined, decimals: number, maxFraction = 8): string | null { + const value = String(raw ?? '').trim() + if (!value) return null + if (!/^-?\d+$/.test(value)) return value + const negative = value.startsWith('-') + const abs = BigInt(negative ? value.slice(1) : value) + const denom = 10n ** BigInt(decimals) + const whole = abs / denom + const fraction = abs % denom + let fractionText = decimals > 0 + ? fraction.toString().padStart(decimals, '0').slice(0, maxFraction).replace(/0+$/, '') + : '' + if (whole === 0n && fraction > 0n && !fractionText) { + fractionText = `${'0'.repeat(Math.max(maxFraction - 1, 0))}1` + return `${negative ? '-' : ''}<0.${fractionText}` + } + return `${negative ? '-' : ''}${commifyInteger(whole.toString())}${fractionText ? `.${fractionText}` : ''}` +} + +function numericDisplayValue(display: string | null): number | null { + if (!display || display.startsWith('<')) return null + const n = Number(display.replace(/,/g, '')) + return Number.isFinite(n) ? n : null +} + +function formatNativeValue(raw: string | undefined, chainDef: { decimals: number; symbol: string } | undefined, source: string, priceUsd?: number) { + if (!raw) return null + const amount = source === 'scan' && chainDef + ? formatBaseUnits(raw, chainDef.decimals) + : raw + if (!amount) return null + const usd = priceUsd ? formatUsd((numericDisplayValue(amount) ?? 0) * priceUsd) : null + return { amount, usd } +} + +function nativePriceByChain(balances: ChainBalance[]): Record { + const prices: Record = {} + for (const b of balances) { + const balance = Number(b.balance) + const tokenUsd = b.tokens?.reduce((sum, t) => sum + (t.balanceUsd || 0), 0) || 0 + const nativeUsd = Number(b.nativeBalanceUsd ?? Math.max((b.balanceUsd || 0) - tokenUsd, 0)) + if (Number.isFinite(balance) && balance > 0 && Number.isFinite(nativeUsd) && nativeUsd > 0) { + prices[b.chainId] = nativeUsd / balance + } + } + return prices +} + // ── Detail types for the TX detail dialog ─────────────────────────── type TxDetail = { kind: 'activity' @@ -121,6 +196,10 @@ type TxDetail = { swap: PendingSwap } +type ActivityTimelineItem = + | { kind: 'activity'; id: string; createdAt: number; activity: RecentActivity } + | { kind: 'swap'; id: string; createdAt: number; swap: PendingSwap } + function formatFullDate(ts: number): string { const d = new Date(ts) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + @@ -144,8 +223,8 @@ function CopyableRow({ label, value, explorerUrl }: { label: string; value: stri {label} {copied ? 'Copied!' : value} @@ -164,15 +243,19 @@ function CopyableRow({ label, value, explorerUrl }: { label: string; value: stri ) } -function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => void }) { +function TxDetailDialog({ detail, onClose, nativePrices }: { detail: TxDetail; onClose: () => void; nativePrices: Record }) { if (detail.kind === 'activity') { const a = detail.activity const typeConf = TYPE_CONFIG[a.type] || TYPE_CONFIG.sign const statusConf = STATUS_CONFIG[a.status] || STATUS_CONFIG.signed - const chainDef = CHAINS.find(c => c.symbol === a.chain || c.id === a.chain) + const chainDef = CHAINS.find(c => a.chainId && c.id === a.chainId) || CHAINS.find(c => c.symbol === a.chain || c.id === a.chain) + const chainSymbol = chainDef?.symbol || a.chain + const nativePrice = chainDef ? nativePrices[chainDef.id] : undefined + const amountValue = formatNativeValue(a.amount, chainDef, a.source, nativePrice) + const feeValue = formatNativeValue(a.fee, chainDef, a.source, nativePrice) const explorerUrl = a.txid && chainDef ? getExplorerTxUrl(chainDef.id, a.txid) : null const explorerAddrUrl = a.to && chainDef?.explorerAddressUrl ? chainDef.explorerAddressUrl.replace('{{address}}', a.to) : null - const required = getRequiredConfs(a.chain) + const required = getRequiredConfs(chainSymbol) return ( @@ -209,15 +292,15 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => fallback={} /> )} - {chainDef?.coin || a.chain} ({a.chain}) + {chainDef?.coin || a.chain} ({chainSymbol}) {/* Amount */} - {a.amount && } + {amountValue && } {/* Fee */} - {a.fee && } + {feeValue && } {/* Separator */} @@ -258,8 +341,8 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => _hover={{ bg: 'rgba(35,220,200,0.18)' }} transition="all 0.15s" onClick={() => rpcRequest('openUrl', { url: explorerUrl }).catch(() => {})} > - View on Explorer - + View on Explorer + @@ -276,7 +359,10 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => // ── Swap detail ─────────────────────────────────────────────────── const s = detail.swap const inboundUrl = getExplorerTxUrl(s.fromChainId, s.txid) - const outboundUrl = s.outboundTxid ? getExplorerTxUrl(s.toChainId, s.outboundTxid) : null + // Refunds outbound on source chain (Maya returns inbound asset). Prefer the + // classifier-populated outboundChainId so refunded ETH→ZEC opens Etherscan, + // not a Zcash explorer pointed at a non-existent hash. + const outboundUrl = s.outboundTxid ? getExplorerTxUrl(s.outboundChainId || s.toChainId, s.outboundTxid) : null const isFinal = s.status === 'completed' || s.status === 'failed' || s.status === 'refunded' return ( @@ -321,7 +407,7 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => )} - {s.error && } + {s.error && } {s.estimatedTime > 0 && } @@ -336,7 +422,7 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => _hover={{ bg: 'rgba(35,220,200,0.18)' }} transition="all 0.15s" onClick={() => rpcRequest('openUrl', { url: inboundUrl }).catch(() => {})} > - Inbound Explorer + Inbound Explorer )} {outboundUrl && ( @@ -347,7 +433,7 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => _hover={{ bg: 'rgba(74,222,128,0.18)' }} transition="all 0.15s" onClick={() => rpcRequest('openUrl', { url: outboundUrl }).catch(() => {})} > - Outbound Explorer + Outbound Explorer )} @@ -357,10 +443,12 @@ function TxDetailDialog({ detail, onClose }: { detail: TxDetail; onClose: () => ) } -function ActivityRow({ activity, onSelect }: { activity: RecentActivity; onSelect: (a: RecentActivity) => void }) { +function ActivityRow({ activity, onSelect, nativePrices }: { activity: RecentActivity; onSelect: (a: RecentActivity) => void; nativePrices: Record }) { const [copied, setCopied] = useState(false) const typeConf = TYPE_CONFIG[activity.type] || TYPE_CONFIG.sign - const chainDef = CHAINS.find(c => c.symbol === activity.chain || c.id === activity.chain) + const chainDef = CHAINS.find(c => activity.chainId && c.id === activity.chainId) || CHAINS.find(c => c.symbol === activity.chain || c.id === activity.chain) + const chainSymbol = chainDef?.symbol || activity.chain + const nativePrice = chainDef ? nativePrices[chainDef.id] : undefined const handleCopy = (text: string) => { navigator.clipboard.writeText(text) @@ -368,63 +456,69 @@ function ActivityRow({ activity, onSelect }: { activity: RecentActivity; onSelec setTimeout(() => setCopied(false), 1500) } - const explorerUrl = activity.txid ? getExplorerUrl(activity.chain, activity.txid) : null + const explorerUrl = activity.txid ? getExplorerUrl(activity.chainId || activity.chain, activity.txid) : null const isUnconfirmed = activity.confirmations !== undefined && activity.confirmations === 0 + const chainLabel = `${chainDef?.coin || activity.chain} (${chainSymbol})` + const nativeAmount = formatNativeValue(activity.amount, chainDef, activity.source, nativePrice) + const nativeFee = formatNativeValue(activity.fee, chainDef, activity.source, nativePrice) + const amountLine = activity.type === 'swap' && (activity.amount || activity.outAmount) + ? `${activity.amount ? `${activity.amount} ${activity.asset || activity.chain}` : ''}${activity.amount || activity.outAmount ? ' \u2192 ' : ''}${activity.outAmount ? `${activity.swapStatus === 'completed' ? '' : '~'}${activity.outAmount} ${activity.outAsset || ''}` : activity.outAsset || '?'}` + : nativeAmount || nativeFee + ? `${nativeAmount ? `${nativeAmount.amount} ${activity.asset || chainSymbol}${nativeAmount.usd ? ` (${nativeAmount.usd})` : ''}` : ''}${nativeAmount && nativeFee ? ' ' : ''}${nativeFee ? `fee: ${nativeFee.amount} ${chainSymbol}${nativeFee.usd ? ` (${nativeFee.usd})` : ''}` : ''}` + : null return ( onSelect(activity)} > - {/* Chain badge */} - - {chainDef ? ( - } - /> - ) : ( - - )} - {chainDef?.coin || activity.chain} ({activity.chain}) - - - - {typeConf.label} - {activity.source === 'api' && ( - API + + + {chainDef ? ( + } + /> + ) : ( + )} + + + {chainLabel} + {typeConf.label} + {activity.source === 'api' && ( + API + )} + + {amountLine && {amountLine}} + + + {activity.type === 'swap' && activity.swapStatus ? ( ) : ( - + )} - - {timeAgo(activity.createdAt)} + {timeAgo(activity.createdAt)} + - {(activity.amount || activity.fee) && ( - - {activity.amount && {activity.amount} {activity.asset || activity.chain}} - {activity.fee && fee: {activity.fee}} - - )} - + {activity.txid ? ( - { e.stopPropagation(); handleCopy(activity.txid!) }} title={copied ? 'Copied!' : 'Click to copy'}> + { e.stopPropagation(); handleCopy(activity.txid!) }} title={copied ? 'Copied!' : 'Click to copy'}> {copied ? 'Copied!' : truncateTxid(activity.txid)} ) : ( - no txid + no txid )} - + {activity.blockHeight ? blk {activity.blockHeight} : null} - {explorerUrl && { e.stopPropagation(); rpcRequest('openUrl', { url: explorerUrl }) }}>Explorer} + {explorerUrl && { e.stopPropagation(); rpcRequest('openUrl', { url: explorerUrl }) }}>Explorer} @@ -435,29 +529,36 @@ function SwapRow({ swap, onSelect }: { swap: PendingSwap; onSelect: (s: PendingS const [copied, setCopied] = useState(false) const handleCopy = (text: string) => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500) } const explorerUrl = getExplorerUrl(swap.fromSymbol, swap.txid) + const amountLine = swap.fromAmount + ? `${swap.fromAmount} ${swap.fromSymbol}${swap.expectedOutput ? ` \u2192 ${swap.expectedOutput} ${swap.toSymbol}` : ''}` + : null return ( - onSelect(swap)} > - - - Swap - {swap.fromSymbol} {'\u2192'} {swap.toSymbol} - - + + + + {swap.fromSymbol} {'\u2192'} {swap.toSymbol} + Swap + + {amountLine && {amountLine}} + + + + {timeAgo(swap.createdAt)} + - {swap.fromAmount && {swap.fromAmount} {swap.fromSymbol}{swap.expectedOutput ? ` \u2192 ${swap.expectedOutput} ${swap.toSymbol}` : ''}} - - { e.stopPropagation(); handleCopy(swap.txid) }} title={copied ? 'Copied!' : 'Click to copy'}> + + { e.stopPropagation(); handleCopy(swap.txid) }} title={copied ? 'Copied!' : 'Click to copy'}> {copied ? 'Copied!' : truncateTxid(swap.txid)} - - {explorerUrl && { e.stopPropagation(); rpcRequest('openUrl', { url: explorerUrl }) }}>Explorer} - {timeAgo(swap.createdAt)} + + {explorerUrl && { e.stopPropagation(); rpcRequest('openUrl', { url: explorerUrl }) }}>Explorer} @@ -486,7 +587,7 @@ function NetworkSelector({ chainOptions, selectedChain, selectedDef, scanning, s const [dropdownOpen, setDropdownOpen] = useState(false) return ( - + {/* Trigger */} All All Networks - {!selectedChain && {'\u2713'}} + {!selectedChain && {'\u2713'}} {chainOptions.map(c => ( {c.symbol} {c.coin} {c.networkId} - {selectedChain === c.id && {'\u2713'}} + {selectedChain === c.id && {'\u2713'}} ))} @@ -625,30 +726,44 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre .filter((c): c is NonNullable => c !== null) .sort((a, b) => b.balanceUsd - a.balanceUsd) }, [availableChains, chainMap]) + const nativePrices = useMemo(() => nativePriceByChain(availableChains), [availableChains]) const selectedDef = useMemo(() => CHAINS.find(c => c.id === selectedChain), [selectedChain]) // Filter activities to selected chain (empty = all) const filteredActivities = useMemo(() => { - if (!selectedDef) return activities - return activities.filter(a => a.chain === selectedDef.symbol) + const next = selectedDef + ? activities.filter(a => a.chainId ? a.chainId === selectedDef.id : (a.chain === selectedDef.symbol || a.chain === selectedDef.id)) + : activities + return recentFirst(next) }, [activities, selectedDef]) const activeSwaps = useMemo(() => - pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), + recentFirst(pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded')), [pendingSwaps] ) const filteredSwaps = useMemo(() => { - if (!selectedDef) return activeSwaps - return activeSwaps.filter(s => s.fromSymbol === selectedDef.symbol || s.toSymbol === selectedDef.symbol) + const next = selectedDef + ? activeSwaps.filter(s => s.fromSymbol === selectedDef.symbol || s.toSymbol === selectedDef.symbol || s.fromChainId === selectedDef.id || s.toChainId === selectedDef.id) + : activeSwaps + return recentFirst(next) }, [activeSwaps, selectedDef]) const nonSwapActivities = useMemo(() => { const swapTxids = new Set(pendingSwaps.map(s => s.txid)) - return filteredActivities.filter(a => !(a.type === 'swap' && a.txid && swapTxids.has(a.txid))) + return recentFirst(filteredActivities.filter(a => !(a.type === 'swap' && a.txid && swapTxids.has(a.txid)))) }, [filteredActivities, pendingSwaps]) + const activityTimeline = useMemo(() => { + return recentFirst([ + ...filteredSwaps.map(swap => ({ kind: 'swap' as const, id: `swap-${swap.txid}`, createdAt: swap.createdAt, swap })), + ...nonSwapActivities.map(activity => ({ kind: 'activity' as const, id: `activity-${activity.id}`, createdAt: activity.createdAt, activity })), + ]) + }, [filteredSwaps, nonSwapActivities]) + + const recentPendingSwaps = useMemo(() => recentFirst(pendingSwaps), [pendingSwaps]) + const fetchingSwapRef = useRef(false) const scanningRef = useRef(false) const handleScan = useCallback(async () => { @@ -682,7 +797,7 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre @@ -730,15 +845,14 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre )} {/* Content */} - - + + {tab === 'activity' && ( <> - {filteredSwaps.map(swap => ( - onResumeSwap ? onResumeSwap(s) : setSelectedDetail({ kind: 'swap', swap: s })} /> - ))} - {nonSwapActivities.map(activity => ( - { + {activityTimeline.map(item => item.kind === 'swap' ? ( + onResumeSwap ? onResumeSwap(s) : setSelectedDetail({ kind: 'swap', swap: s })} /> + ) : ( + { if (a.type === 'swap' && a.txid && onResumeSwap) { if (fetchingSwapRef.current) return fetchingSwapRef.current = true @@ -751,19 +865,19 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre } }} /> ))} - {nonSwapActivities.length === 0 && filteredSwaps.length === 0 && ( + {activityTimeline.length === 0 && ( - {selectedDef ? `No activity for ${selectedDef.symbol} — hit refresh to scan` : 'No activity yet — select a chain and hit refresh to scan'} + {selectedDef ? `No indexed ${selectedDef.symbol} activity. Refresh to scan.` : 'No indexed activity loaded. Choose a network to scan.'} )} )} {tab === 'swaps' && ( <> - {pendingSwaps.map(swap => ( + {recentPendingSwaps.map(swap => ( onResumeSwap ? onResumeSwap(s) : setSelectedDetail({ kind: 'swap', swap: s })} /> ))} - {pendingSwaps.length === 0 && ( + {recentPendingSwaps.length === 0 && ( No swaps )} @@ -774,7 +888,7 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre {/* TX Detail Dialog */} {selectedDetail && ( - setSelectedDetail(null)} /> + setSelectedDetail(null)} /> )} ) diff --git a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx index 134d6573..b9d0579b 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx @@ -10,7 +10,7 @@ import { rpcRequest, onRpcMessage } from "../lib/rpc" import { Z } from "../lib/z-index" import { ActivityPanel } from "./ActivityPanel" import { SwapDialog } from "./SwapDialog" -import type { RecentActivity, PendingSwap, SwapStatusUpdate, ApiLogEntry } from "../../shared/types" +import type { RecentActivity, PendingSwap, SwapStatusUpdate, ApiLogEntry, DeviceStateInfo } from "../../shared/types" const TRACKER_CSS = ` @keyframes kkActivityPulse { @@ -41,6 +41,7 @@ export function ActivityTracker() { const [hasNew, setHasNew] = useState(false) const [bouncing, setBouncing] = useState(false) const lastCountRef = useRef(0) + const lastDeviceStateKeyRef = useRef('') const bounceTimeoutRef = useRef>() // Fetch recent activities from api_log + swap_history (unified query) @@ -60,6 +61,25 @@ export function ActivityTracker() { // Fetch on mount useEffect(() => { fetchActivities(); fetchSwaps() }, [fetchActivities, fetchSwaps]) + // Device and seed changes can happen without remounting this component. + // Clear first so the previous wallet's activity is never displayed while + // the scoped backend query is catching up. + useEffect(() => { + const unsub = onRpcMessage('device-state', (state: DeviceStateInfo) => { + const stateKey = `${state.state}:${state.deviceId || ''}:${state.isHiddenWallet ? 'hidden' : 'standard'}` + if (stateKey !== lastDeviceStateKeyRef.current) { + lastDeviceStateKeyRef.current = stateKey + setActivities([]) + setPendingSwaps([]) + } + if (state.state === 'ready' && state.deviceId && !state.isHiddenWallet) { + fetchActivities() + fetchSwaps() + } + }) + return unsub + }, [fetchActivities, fetchSwaps]) + // Listen for new api-log entries — re-fetch if it's a sign/broadcast useEffect(() => { const unsub = onRpcMessage('api-log', (entry: ApiLogEntry) => { @@ -126,9 +146,22 @@ export function ActivityTracker() { lastCountRef.current = totalCount }, [totalCount]) + useEffect(() => { + if (!panelOpen || activities.length > 0 || pendingSwaps.length > 0) return + fetchActivities() + fetchSwaps() + const interval = setInterval(() => { + fetchActivities() + fetchSwaps() + }, 2500) + return () => clearInterval(interval) + }, [panelOpen, activities.length, pendingSwaps.length, fetchActivities, fetchSwaps]) + const handleOpen = () => { setPanelOpen(true) setHasNew(false) + fetchActivities() + fetchSwaps() } // Label diff --git a/projects/keepkey-vault/src/mainview/components/AddChainDialog.tsx b/projects/keepkey-vault/src/mainview/components/AddChainDialog.tsx index 7ad7ad4a..910c6349 100644 --- a/projects/keepkey-vault/src/mainview/components/AddChainDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AddChainDialog.tsx @@ -473,7 +473,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC {mode === 'configure' && selectedChain && ( {/* Chain identity badge */} - + {selectedChain.symbol} { @@ -583,7 +583,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={rpcUrl} onChange={(e) => { setRpcUrl(e.target.value); setTested(false); setError(null) }} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -616,7 +616,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={explorerAddressLink} onChange={(e) => setExplorerAddressLink(e.target.value)} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -632,7 +632,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={explorerTxLink} onChange={(e) => setExplorerTxLink(e.target.value)} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -676,7 +676,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={chainId} onChange={(e) => { setChainId(e.target.value.replace(/\D/g, '')); setTested(false); setError(null) }} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -691,7 +691,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={name} onChange={(e) => setName(e.target.value)} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -706,7 +706,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={symbol} onChange={(e) => setSymbol(e.target.value)} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -721,7 +721,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={rpcUrl} onChange={(e) => { setRpcUrl(e.target.value); setTested(false); setError(null) }} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -752,7 +752,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={explorerAddressLink} onChange={(e) => setExplorerAddressLink(e.target.value)} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" @@ -768,7 +768,7 @@ export function AddChainDialog({ onClose, onAdded, existingChainIds = [] }: AddC value={explorerTxLink} onChange={(e) => setExplorerTxLink(e.target.value)} size="sm" - bg="#1A1A1A" + bg="var(--ink-2)" border="1px solid" borderColor="kk.border" color="white" diff --git a/projects/keepkey-vault/src/mainview/components/AddTokenDialog.tsx b/projects/keepkey-vault/src/mainview/components/AddTokenDialog.tsx index 4299fe33..05b2bb6d 100644 --- a/projects/keepkey-vault/src/mainview/components/AddTokenDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AddTokenDialog.tsx @@ -118,7 +118,7 @@ export function AddTokenDialog({ defaultChainId, onClose, onAdded }: AddTokenDia {/* Result */} {result && ( - + {result.symbol} {result.name} {result.decimals} {t('addToken.decimals')} diff --git a/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx b/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx index 5d1a7934..2ccdf278 100644 --- a/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx @@ -13,7 +13,7 @@ interface ApiAuditLogProps { } function StatusBadge({ status }: { status: number }) { - const color = status < 300 ? "#4ADE80" : status < 500 ? "#FB923C" : "#FF6B6B" + const color = status < 300 ? "var(--teal)" : status < 500 ? "#FB923C" : "#FF6B6B" return ( - + {letter} @@ -146,9 +146,9 @@ function JsonBlock({ label, data }: { label: string; data: any }) { as="button" fontSize="9px" fontWeight="500" - color={copied ? "#4ADE80" : "kk.textMuted"} + color={copied ? "var(--teal)" : "kk.textMuted"} cursor="pointer" - _hover={{ color: copied ? "#4ADE80" : "kk.textSecondary" }} + _hover={{ color: copied ? "var(--teal)" : "kk.textSecondary" }} onClick={handleCopy} transition="color 0.15s" > @@ -219,7 +219,7 @@ export function ApiAuditLog({ open, entries, onClose, side = "right" }: ApiAudit zIndex={1} > - + @@ -255,7 +255,7 @@ export function ApiAuditLog({ open, entries, onClose, side = "right" }: ApiAudit py="2.5" borderBottom="1px solid" borderColor="kk.border" - bg="rgba(192,168,96,0.04)" + bg="rgba(233,196,106,0.04)" > {t('auditLogPanel.pairedAppsCount', { count: pairedApps.length })} diff --git a/projects/keepkey-vault/src/mainview/components/AppStore.tsx b/projects/keepkey-vault/src/mainview/components/AppStore.tsx index e58f3cb7..372d6f09 100644 --- a/projects/keepkey-vault/src/mainview/components/AppStore.tsx +++ b/projects/keepkey-vault/src/mainview/components/AppStore.tsx @@ -6,26 +6,15 @@ interface AppDef { name: string description: string icon: string - /** Inline SVG fallback when icon URL may 404 */ - iconFallback?: JSX.Element url: string enabled: boolean badge?: string /** If true, this app is internal (switches tab) rather than opening a URL */ internal?: boolean + /** Brand accent — drives the soft blur tint in the card's top-right corner */ + accent?: string } -/** WalletConnect logo as inline SVG (CDN icon unreliable) */ -const WalletConnectIcon = () => ( - - - - -) - function useApps(): AppDef[] { const { t } = useTranslation("appstore") return [ @@ -36,6 +25,7 @@ function useApps(): AppDef[] { icon: "https://pioneers.dev/coins/keepkey.png", url: "https://vault.keepkey.com", enabled: true, + accent: "var(--gold)", }, { id: "shapeshift", @@ -44,15 +34,7 @@ function useApps(): AppDef[] { icon: "https://pioneers.dev/coins/fox.png", url: "https://app.shapeshift.com", enabled: true, - }, - { - id: "walletconnect", - name: t("walletconnectName"), - description: t("walletconnectDescription"), - icon: "", - iconFallback: , - url: "https://wallet-connect-dapp-ochre.vercel.app", - enabled: true, + accent: "var(--teal)", }, ] } @@ -60,99 +42,143 @@ function useApps(): AppDef[] { interface AppStoreProps { onOpenApp: (url: string) => void onOpenKeepKey: () => void - onOpenWalletConnect?: () => void } -export function AppStore({ onOpenApp, onOpenKeepKey, onOpenWalletConnect }: AppStoreProps) { +export function AppStore({ onOpenApp, onOpenKeepKey }: AppStoreProps) { const { t } = useTranslation("appstore") const APPS = useApps() const handleClick = (app: AppDef) => { if (!app.enabled) return - if (app.id === "walletconnect" && onOpenWalletConnect) { - onOpenWalletConnect() - return - } - if (app.internal) { - onOpenKeepKey() - } else if (app.url) { - onOpenApp(app.url) - } + if (app.internal) onOpenKeepKey() + else if (app.url) onOpenApp(app.url) } return ( - - - - {t("title")} - - - {t("subtitle")} - - + + + {/* Hero header — eyebrow + Instrument Serif italic title */} + + + {t("title")} + + + Apps + + + {t("subtitle")} + + + + {APPS.map((app) => ( handleClick(app)} + position="relative" + overflow="hidden" > - - {app.iconFallback ? ( - - {app.iconFallback} - - ) : ( - - )} - - - - {app.name} - - {app.badge && ( - - {app.badge} - - )} - - - {app.description} + {/* soft accent glow in the corner */} + {app.enabled && app.accent && ( + + )} + + + + + + + + {app.name} + + {app.badge && ( + + {app.badge} - - {app.enabled && !app.internal && ( - - - - - )} + + {app.description} + + + {app.enabled && ( + + {!app.internal && ( + + + + + )} + {app.internal ? t("internal", { defaultValue: "Open" }) : t("open", { defaultValue: "Open" })} + + )} ))} diff --git a/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx b/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx new file mode 100644 index 00000000..c2ca5f5a --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx @@ -0,0 +1,110 @@ +/** + * AssetIcon — Token logo with optional network badge. + * + * For tokens, pass `chainCaip` to render a small chain-logo badge in the + * bottom-right corner — distinguishes e.g. USDT-on-ETH from USDT-on-TRON. + * For native assets, omit `chainCaip` (or pass it equal to `caip`) and the + * badge is suppressed (it would just duplicate the main logo). + * + * The badge is also auto-suppressed below `size={24}` since it would render + * as visual mush at very small sizes. + */ +import { useState } from "react" +import { Box, Image, Text } from "@chakra-ui/react" +import { caipToIcon, getAssetIcon } from "../../shared/assetLookup" + +interface AssetIconProps { + /** Asset CAIP (token CAIP for ERC-20s, chain CAIP for natives) */ + caip?: string + /** Direct icon URL — wins over `caip` lookup when provided */ + iconUrl?: string + /** Chain CAIP for the network badge. Omit for natives; omit to suppress. */ + chainCaip?: string + /** Pixel size of the main icon */ + size: number + /** Accessible alt text */ + alt?: string + /** Optional ring color override (default: subtle white) */ + ring?: string +} + +const FALLBACK_BG = "rgba(255,255,255,0.06)" + +/** Render a clean letter-bubble fallback when the icon image fails to load. + * We never use `alt` text as the visible fallback — the browser default for + * broken images is to render the alt text raw, which on a fixed-size circular + * bubble clips ugly (e.g. "USDT" → "JSDT" with the U cut off). Using a + * positioned with overflow:hidden + the symbol initial gives a + * predictable shape at every size. */ +export function AssetIcon({ caip, iconUrl, chainCaip, size, alt, ring }: AssetIconProps) { + const mainSrc = iconUrl || (caip ? getAssetIcon(caip) : undefined) + const showBadge = !!chainCaip && chainCaip !== caip && size >= 24 + const badgeSize = Math.max(12, Math.round(size * 0.38)) + const [mainBroken, setMainBroken] = useState(false) + const [badgeBroken, setBadgeBroken] = useState(false) + const initial = (alt || '?').trim().charAt(0).toUpperCase() || '?' + + return ( + + + {mainSrc && !mainBroken ? ( + setMainBroken(true)} + /> + ) : ( + + {initial} + + )} + + {showBadge && ( + + {!badgeBroken ? ( + setBadgeBroken(true)} + /> + ) : null} + + )} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 4de3662e..d74cb29e 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -1,8 +1,8 @@ -import React, { lazy, Suspense, useState, useEffect, useCallback, useMemo } from "react" +import React, { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from "react" import { useTranslation } from "react-i18next" import { Box, Flex, Text, Button, Image, VStack, HStack, IconButton, Spinner } from "@chakra-ui/react" -import { FaArrowDown, FaArrowUp, FaExchangeAlt, FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck } from "react-icons/fa" -import { rpcRequest } from "../lib/rpc" +import { FaArrowDown, FaArrowUp, FaExchangeAlt, FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck, FaCopy } from "react-icons/fa" +import { rpcRequest, onRpcMessage } from "../lib/rpc" import type { ChainDef } from "../../shared/chains" import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported } from "../../shared/chains" import type { ChainBalance, TokenBalance, TokenVisibilityStatus, AppSettings } from "../../shared/types" @@ -53,6 +53,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage const { fmtCompact, symbol: fiatSymbol } = useFiat() const [view, setView] = useState("receive") const [selectedToken, setSelectedToken] = useState(null) + const [copiedCaip, setCopiedCaip] = useState(null) const [address, setAddress] = useState(balance?.address || null) const [loading, setLoading] = useState(false) const [deriveError, setDeriveError] = useState(null) @@ -80,7 +81,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage }, [chain.id, isBtc, refreshBtcAccounts]) // Use refreshed balance if available, otherwise prop - const activeBalance = refreshedBalance || balance + const baseBalance = refreshedBalance || balance // Feature flags: swaps, zcash privacy const [swapsEnabled, setSwapsEnabled] = useState(false) @@ -115,6 +116,22 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage // EVM multi-address support const isEvm = chain.chainFamily === 'evm' const { evmAddresses, selectIndex: evmSelectIndex, addIndex: evmAddIndex, removeIndex: evmRemoveIndex, loading: evmLoading } = useEvmAddresses() + const previousEvmSelectedIndex = useRef(null) + const selectedEvmAddress = isEvm + ? evmAddresses.addresses.find(a => a.addressIndex === evmAddresses.selectedIndex) + : undefined + const selectedEvmChainBalance = selectedEvmAddress?.chainBalances?.[chain.id] + const activeBalance: ChainBalance | undefined = isEvm && selectedEvmAddress && selectedEvmChainBalance + ? { + chainId: chain.id, + symbol: chain.symbol, + balance: selectedEvmChainBalance.balance, + balanceUsd: selectedEvmChainBalance.balanceUsd, + nativeBalanceUsd: selectedEvmChainBalance.nativeBalanceUsd, + address: selectedEvmAddress.address, + tokens: selectedEvmChainBalance.tokens, + } + : baseBalance // BTC address index state: change (0=receive, 1=change) and address index const [btcChangeIndex, setBtcChangeIndex] = useState<0 | 1>(0) @@ -236,6 +253,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage if (!isEvm || evmAddresses.addresses.length === 0) return const selected = evmAddresses.addresses.find(a => a.addressIndex === evmAddresses.selectedIndex) if (selected) { + if (previousEvmSelectedIndex.current !== null && previousEvmSelectedIndex.current !== selected.addressIndex) { + setSelectedToken(null) + } + previousEvmSelectedIndex.current = selected.addressIndex setAddress(selected.address) setCurrentPath([0x8000002C, 0x8000003C, 0x80000000, 0, selected.addressIndex]) } @@ -272,11 +293,16 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage const [visibilityMap, setVisibilityMap] = useState>({}) const [showHidden, setShowHidden] = useState(false) - // Load visibility overrides once on mount + // Load visibility overrides + refetch on push so changes from Dashboard + // or another AssetPage tab stay in sync without a full reload. useEffect(() => { - rpcRequest>('getTokenVisibilityMap', undefined, 5000) - .then(setVisibilityMap) - .catch(() => {}) + const refetch = () => { + rpcRequest>('getTokenVisibilityMap', undefined, 5000) + .then(setVisibilityMap) + .catch(() => {}) + } + refetch() + return onRpcMessage('token-visibility-changed', refetch) }, []) const { cleanTokens, spamTokens, zeroValueTokens, spamResults } = useMemo(() => { @@ -284,8 +310,12 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v] as const), ) const results = new Map() + console.log(`[spamFilter] evaluating ${tokens.length} tokens, ${overrides.size} overrides`) for (const t of tokens) { - results.set(t.caip, detectSpamToken(t, overrides.get(t.caip?.toLowerCase()) ?? null)) + const override = overrides.get(t.caip?.toLowerCase()) ?? null + const result = detectSpamToken(t, override) + results.set(t.caip, result) + console.log(`[spamFilter] ${t.symbol} caip="${t.caip}" contract="${t.contractAddress}" qty=${t.balance} usd=${t.balanceUsd} override=${override} → ${result.isSpam ? `SPAM(${result.level})` : 'clean'} — ${result.reason}`) } const { clean, spam, zeroValue } = categorizeTokens(tokens, overrides) return { @@ -309,11 +339,17 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage // Toggle token visibility via RPC const handleSetVisibility = useCallback(async (caip: string, status: TokenVisibilityStatus) => { + console.log(`[hideToken] ▶ caip="${caip}" status="${status}"`) try { await rpcRequest('setTokenVisibility', { caip, status }, 5000) + console.log(`[hideToken] ✓ stored`) setVisibilityMap(prev => ({ ...prev, [caip.toLowerCase()]: status })) + // Collapse the "show filtered" section when hiding so the token disappears + // immediately. Without this, the token moves to spam bucket but stays visible + // because the section is expanded, making the hide appear broken. + if (status === 'hidden') setShowHidden(false) } catch (e: any) { - console.warn('[AssetPage] setTokenVisibility failed:', e.message) + console.error(`[hideToken] ✗ RPC failed: ${e.message}`, e) } }, []) @@ -405,6 +441,27 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage {tok.name} + {tok.contractAddress && ( + { + e.stopPropagation() + navigator.clipboard.writeText(tok.contractAddress!) + setCopiedCaip(tok.caip) + setTimeout(() => setCopiedCaip(c => c === tok.caip ? null : c), 1500) + }} + _hover={{ color: "kk.textSecondary" }} + title={`Click to copy: ${tok.contractAddress}`} + color="kk.textMuted" + > + + {tok.contractAddress} + + + + )} @@ -431,7 +488,8 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage )} - {!spamResult?.isSpam && !isUserHidden && ( + {/* Hide always available: clean tokens + spam tokens in expanded section */} + {(!spamResult?.isSpam || opts?.showActions) && !isUserHidden && !isUserSafe && ( - - {/* Header */} - - - - {chain.coin} - {chain.symbol} - {activeBalance && ( - - - {activeBalance.balance} {chain.symbol} + + + {/* Header — back button + chain identity hero + sync status + refresh */} + + + + + + + + + + + + + + {chain.coin} + + {chain.symbol} + + + {chain.caip} - {cleanBalanceUsd > 0 && ( - - )} - - - {refreshing ? ( - - ) : ( - - - - - )} - {refreshing ? t("refreshing") : t("refresh")} - - - - )} - - {/* CAIP badge */} - - - {chain.caip} - + + + + + {/* Sync status indicator */} + {activeBalance ? ( + + + {t("synced")} + + ) : ( + + + {t("outOfSync")} + + )} + {/* Balance display (only when available) */} + {activeBalance && ( + + + {activeBalance.balance} + {chain.symbol} + + {cleanBalanceUsd > 0 && ( + + )} + + )} + + + + {refreshing ? ( + + ) : ( + + + + + )} + - {/* Pill toggle */} - - - {PILLS.map((p) => ( - - ))} + {/* Mobile-only balance row */} + {activeBalance && ( + + + {activeBalance.balance} + {chain.symbol} + + {cleanBalanceUsd > 0 && ( + + )} + + )} + + {/* Action tabs — v3 pill toggle, gold active fill */} + + + {PILLS.map((p) => { + const isActive = view === p.id + return ( + { + if (p.id === 'swap') { setShowSwapDialog(true); return } + setView(p.id as AssetView); if (p.id === 'receive') setSelectedToken(null) + }} + display="flex" + alignItems="center" + gap="2" + px={{ base: "5", md: "6" }} + py="2.5" + borderRadius="999px" + fontSize="13px" + fontWeight="500" + letterSpacing="-0.005em" + color={isActive ? "var(--ink-0)" : "var(--text-2)"} + bg={isActive ? "var(--gold)" : "transparent"} + _hover={isActive ? {} : { color: "var(--text-0)", bg: "var(--ink-3)" }} + transition="all 0.18s" + cursor="pointer" + minW="110px" + justifyContent="center" + > + + {p.label} + + ) + })} @@ -618,7 +756,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage )} {/* Content */} - + {view === "send" ? ( isBtc && !btcSelected?.xpubData ? ( @@ -670,7 +808,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage {/* Staking section — Cosmos-family chains */} {chain.chainFamily === 'cosmos' && (chain.id === 'cosmos' || chain.id === 'osmosis') && ( - + }> 0 || isEvmChain) && ( - - - - {t("tokens")} + + + + {t("tokens")}{cleanTokens.length > 0 && ` · ${cleanTokens.length}`} - {cleanTokens.length > 0 && ( - - {t("tokenCount", { count: cleanTokens.length })} - - )} {tokenTotalUsd > 0 && ( - {fmtCompact(tokenTotalUsd)} + {fmtCompact(tokenTotalUsd)} )} {isEvmChain && ( setShowAddToken(true)} > @@ -795,9 +928,9 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage w="40px" h="40px" borderRadius="full" - bg="rgba(74,222,128,0.08)" + bg="rgba(139,227,196,0.08)" border="1px solid" - borderColor="rgba(74,222,128,0.15)" + borderColor="rgba(139,227,196,0.18)" display="flex" alignItems="center" justifyContent="center" @@ -806,8 +939,8 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage opacity={0.6} _hover={{ opacity: 1, - bg: "rgba(74,222,128,0.18)", - borderColor: "rgba(74,222,128,0.4)", + bg: "rgba(139,227,196,0.18)", + borderColor: "rgba(139,227,196,0.4)", transform: "scale(1.08)", }} _active={{ transform: "scale(0.95)" }} @@ -815,7 +948,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage zIndex={10} title="Sweep Scanner — find BTC on non-standard paths & higher accounts" > - + diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx new file mode 100644 index 00000000..ec2a9142 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -0,0 +1,497 @@ +/** + * Modal-over-modal asset picker. Opens on top of SwapDialog. + * + * Differs from the inline AssetSelector it replaces: + * - Searches the entire pioneer-discovery universe (~30k CAIPs), not just + * Pioneer's swappable subset. + * - Bucket-sorted: held → Pioneer-swappable → matrix-swappable → unknown + * → unsupported. Ranked when the user types. + * - Per-row availability badge with reason (so the user understands why + * a token they searched for can't be swapped). + * - Caps render volume: empty query shows only held + swappable buckets; + * typing expands the surface to the full universe. + */ +import { useState, useEffect, useMemo, useRef, useCallback } from "react" +import { Box, Flex, Text, Input, Button } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" +import { AssetIcon } from "./AssetIcon" +import type { SwapAsset, ChainBalance, CustomToken } from "../../shared/types" +import { + buildAssetEntries, + buildSearchIndex, + searchEntries, + bucketFor, + chainMetaForCaip2, + networkDisplayName, + synthesizeSwapAsset, + type AssetEntry, + type SearchIndex, +} from "../../shared/swap-discovery" +import { PROVIDER_LABEL, type AvailabilityStatus } from "../../shared/swap-support-matrix" +import { Z } from "../lib/z-index" +import { useFiat } from "../lib/fiat-context" +import { rpcRequest } from "../lib/rpc" +import { networkDisplayName as nd } from "../../shared/swap-discovery" + +const EVM_CONTRACT_RE = /^0x[a-fA-F0-9]{40}$/ + +const MAX_RENDER = 200 + +const SearchIcon = () => ( + + + + +) + +interface AssetPickerDialogProps { + open: boolean + onClose: () => void + /** Pioneer GetAvailableAssets cached result. */ + swappable: SwapAsset[] + /** Connected wallet's per-chain balances. */ + balances: ChainBalance[] + /** User-added custom tokens (gnars on Base etc.) — surfaced in the picker + * even when neither discovery nor Pioneer's swappable list contains them. */ + customTokens?: CustomToken[] + /** CAIP-19 of the asset on the OPPOSITE side of the swap — excluded so the + * user can't pick the same asset on both legs. */ + excludeCaip?: string + /** Fired when the user picks a Pioneer-swappable asset. The dialog refuses + * to fire onSelect for non-swappable rows (they're rendered disabled). */ + onSelect: (asset: SwapAsset) => void + /** Whether this picker is for the FROM side ("From which asset?") or TO. */ + side: "from" | "to" +} + +function chainBadgeCaip(entry: AssetEntry): string | undefined { + // For tokens, AssetIcon's chainCaip prop expects the chain's full CAIP-19 + // native asset id (e.g. 'eip155:1/slip44:60') — that's what caipToIcon + // base64-encodes for the keepkey.info URL. Passing the bare CAIP-2 'eip155:1' + // produced a broken URL and a missing badge in v1. + if (entry.isNative) return undefined + return chainMetaForCaip2(entry.chainId)?.nativeCaip +} + +/** Decide whether a row is selectable. + * + * Two gates: + * 1. Matrix says swappable or unknown (try-quote). + * 2. Vault has a ChainDef for this chain — without one we can't derive + * the destination address, sign for the source, or build the tx. + * Without the gate, matrix-swappable chains like Berachain/Linea/ + * Celo/Sonic etc. (Relay routes them, but vault has no chain entry) + * rendered as selectable then silently swallowed the click — the + * synthesizer returned null because chainMetaForCaip2 was null. */ +function isRowSelectable(entry: AssetEntry): boolean { + const status = entry.availability.status + if (status !== 'swappable' && status !== 'unknown') return false + return chainMetaForCaip2(entry.chainId) !== null +} + +/** Build a human-readable reason for why an asset can't be selected (or has + * ambiguous availability). The matrix returns CAIP-formatted reasons like + * "tron:27Lqcw is not currently supported"; this swaps in the chain's + * display name and produces something a user can act on. Returns null only + * for cleanly swappable rows that vault can also operate on. */ +function humanReason(entry: AssetEntry): string | null { + const status = entry.availability.status + const chain = networkDisplayName(entry.chainId) + const vaultKnowsChain = chainMetaForCaip2(entry.chainId) !== null + + // Matrix says we can route, but vault has no ChainDef → can't sign or + // derive an address → not actually selectable. This catches Berachain / + // Linea / Celo / Sonic / Mode / Manta / Mantle / Scroll / zkSync / Blast + // — the matrix added them per Relay coverage but vault's chains.ts doesn't + // have entries yet, so until that lands the picker has to be honest. + if ((status === 'swappable' || status === 'unknown') && !vaultKnowsChain) { + return `${chain} routing is supported but vault doesn't have this chain configured yet — sign and address-derive paths are blocked.` + } + if (status === 'swappable') return null + if (status === 'unknown') { + return `${chain} is supported — Pioneer didn't pre-list this token, but a quote may still route via aggregators (try it).` + } + if (status === 'unsupported_token') { + return `${chain} natives swap fine, but this specific token isn't routable through any provider yet.` + } + // unsupported_chain + return `${chain} isn't supported by any swap provider yet (THORChain, Mayachain, Relay, 0x, ChainFlip, ShapeShift).` +} + +export function AssetPickerDialog({ + open, onClose, swappable, balances, customTokens, excludeCaip, onSelect, side, +}: AssetPickerDialogProps) { + const { t } = useTranslation("swap") + const { fmtCompact } = useFiat() + const [search, setSearch] = useState("") + const [entries, setEntries] = useState(null) + const [loading, setLoading] = useState(false) + const inputRef = useRef(null) + + /* Paste-contract auto-add: when the search query is a valid EVM address + * and the discovery universe has no matches, we offer to fetch the + * token's metadata directly from the chain RPC and add it as a custom + * swap asset. The lookup is debounced so we don't fire it on every + * keystroke while pasting. */ + const [contractHits, setContractHits] = useState(null) + const [contractLooking, setContractLooking] = useState(false) + const [contractError, setContractError] = useState(null) + + // Lazy-build the unified entry list on first open. Recompute when swappable + // or balances change so newly-detected tokens show up. + useEffect(() => { + if (!open) return + let cancelled = false + setLoading(true) + buildAssetEntries({ swappable, balances, customTokens }) + .then(list => { if (!cancelled) { setEntries(list); setLoading(false) } }) + .catch(e => { + if (cancelled) return + console.error("[AssetPickerDialog] buildAssetEntries failed:", e) + setLoading(false) + }) + return () => { cancelled = true } + }, [open, swappable, balances, customTokens]) + + // Reset query and focus search input on each open + useEffect(() => { + if (!open) return + setSearch("") + const id = setTimeout(() => inputRef.current?.focus(), 50) + return () => clearTimeout(id) + }, [open]) + + // Esc closes the picker — keyboard convention. Only bound while open so we + // don't intercept keystrokes meant for SwapDialog or other components. + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onClose]) + + const searchIndex: SearchIndex | null = useMemo( + () => entries ? buildSearchIndex(entries) : null, + [entries], + ) + + const visible = useMemo(() => { + if (!searchIndex) return [] + let list = searchEntries(searchIndex, search) + if (excludeCaip) list = list.filter(e => e.caip !== excludeCaip) + // Empty query: only show held + Pioneer-swappable + matrix-swappable + // (buckets 0-5). Saves rendering ~30k DOM nodes when nothing is typed. + if (!search.trim()) list = list.filter(e => bucketFor(e) <= 5) + return list.slice(0, MAX_RENDER) + }, [searchIndex, search, excludeCaip]) + + /* Probe the chain RPCs when the user pastes a contract that doesn't match + * anything in discovery. Debounced 350ms so a fast-typed address doesn't + * fire 40 lookups. Reset on close / query change. */ + useEffect(() => { + setContractHits(null) + setContractError(null) + if (!open) return + const q = search.trim() + if (!EVM_CONTRACT_RE.test(q)) return + if (visible.length > 0) return /* discovery already had it — skip lookup */ + let cancelled = false + setContractLooking(true) + const timer = setTimeout(() => { + rpcRequest<{ hits: SwapAsset[]; reason?: string }>('lookupTokenContract', { contractAddress: q }, 12000) + .then(res => { + if (cancelled) return + setContractLooking(false) + if (res.hits && res.hits.length > 0) setContractHits(res.hits) + else setContractError(res.reason || 'no-token-found') + }) + .catch(e => { + if (cancelled) return + setContractLooking(false) + setContractError(e?.message || 'lookup-failed') + }) + }, 350) + return () => { cancelled = true; clearTimeout(timer) } + }, [open, search, visible.length]) + + const handleSelect = useCallback((entry: AssetEntry) => { + if (!isRowSelectable(entry)) return + // Prefer Pioneer-listed SwapAsset (canonical asset name + verified routing). + // Fall back to a synthesized one when Pioneer didn't include this CAIP — + // matches the "try quote" UX and lets Pioneer reject with a real reason + // instead of the picker silently swallowing the click. + const base = entry.swappable ?? synthesizeSwapAsset(entry) + if (!base) { + console.warn('[AssetPickerDialog] No vault chain config for', entry.chainId, '- refusing select') + return + } + // Force the outgoing CAIP to match the canonicalized form the picker + // resolved to (entry.caip went through canonicalizeCaip at build time; + // base.caip is whatever pioneer-server emitted, which can drift — + // currently pioneer-server uses /erc20: for BSC tokens but discovery + // emits /bep20:; mirror this in case pioneer-server ever diverges). + const asset = base.caip === entry.caip ? base : { ...base, caip: entry.caip } + onSelect(asset) + onClose() + }, [onSelect, onClose]) + + if (!open) return null + + const title = side === "from" ? t("selectFromAsset", "Select asset to swap from") : t("selectToAsset", "Select asset to swap to") + + return ( + + + e.stopPropagation()} + > + {/* Header */} + + {title} + + + + {/* Search input */} + + + + + {/* List */} + + {loading && ( + {t("loading", "Loading…")} + )} + {!loading && visible.length === 0 && ( + + {/* Paste-contract auto-add lane. + * Triggers when the user types/pastes a 0x..40-char address + * and discovery has no entry for it. We probe every EVM RPC + * in parallel and surface every chain that returned valid + * ERC20 metadata as a one-click "Add as custom token" row. */} + {EVM_CONTRACT_RE.test(search.trim()) ? ( + <> + {contractLooking && ( + + {t("contractLookingUp", "Looking up contract on every chain…")} + + )} + {!contractLooking && contractHits && contractHits.length > 0 && ( + + + {t("contractFoundOn", "Found on", { count: contractHits.length })} + + + {contractHits.map(hit => ( + { onSelect(hit); onClose() }} + > + + + + {hit.symbol} + + {nd(hit.chainId)} + + + {hit.name} + + + {t("addCustomToken", "Add")} + + + ))} + + + {t("contractSafetyNote", "Custom tokens skip Vault's verified list. Verify the symbol matches the project before swapping.")} + + + )} + {!contractLooking && contractHits && contractHits.length === 0 && ( + + {t("contractNotFoundOnAnyChain", "No ERC20 found at this address on any supported chain.")} + + )} + {!contractLooking && !contractHits && contractError && ( + + {t("contractLookupFailed", "Couldn't reach a chain RPC to look up this contract. Try again.")} + + )} + + ) : ( + + {search.trim() + ? t("noAssetsMatchSearch", "No assets match your search.") + : t("noAssetsAvailable", "No swappable assets available.")} + + )} + + )} + {!loading && visible.map(e => )} + {!loading && visible.length === MAX_RENDER && ( + + {t("resultsCapped", "Showing first {{n}} matches — refine your search to narrow down.", { n: MAX_RENDER })} + + )} + + + {/* Hint footer when query is empty */} + {!loading && !search.trim() && ( + + + {t("emptyQueryHint", "Showing held + swappable assets. Type to search the full {{count}}-asset universe.", { count: entries?.length ?? 0 })} + + + )} + + + ) +} + +interface AssetRowProps { + entry: AssetEntry + onSelect: (entry: AssetEntry) => void + fmtCompact: (v: number) => string + t: any // i18next TFunction — overloaded enough that typing it explicitly is more pain than value +} + +function AssetRow({ entry, onSelect, fmtCompact, t }: AssetRowProps) { + const status = entry.availability.status + const selectable = isRowSelectable(entry) + // For tokens, surface the network so USDT-on-ETH vs USDT-on-BSC is unambiguous. + // Falls back to discovery's chain name (covers chains vault doesn't have a ChainDef for). + const networkLabel = networkDisplayName(entry.chainId) + const reason = humanReason(entry) + const isUnsupported = !selectable + + return ( + { if (selectable) onSelect(entry) }} + borderLeft={isUnsupported ? "3px solid rgba(255,99,99,0.35)" : "3px solid transparent"} + > + + + + + {entry.symbol} + {!entry.isNative && ( + + {t("on", "on")} {networkLabel} + + )} + + + {entry.name} + · {entry.chainId} + + + + {/* Right side: balance OR compact availability badge. Held assets + show balance + USD; selectable non-held show the badge. Disabled + (unsupported) rows render the reason inline below instead. */} + {entry.balance ? ( + + {entry.balance.amount} + {entry.balance.usd > 0 && ( + {fmtCompact(entry.balance.usd)} + )} + + ) : ( + + )} + + + {/* Inline humanized reason — surfaced on EVERY non-swappable row so the + user understands why a row is greyed without hovering. The matrix + reason is CAIP-formatted; humanReason swaps in the chain display + name (e.g. "TON" not "ton:-239") via networkDisplayName. */} + {reason && ( + + {reason} + + )} + + ) +} + +function AvailabilityBadge({ entry, t }: { entry: AssetEntry; t: AssetRowProps["t"] }) { + const status = entry.availability.status + const providers = entry.availability.providers + + if (status === "swappable" && providers.length > 0) { + const label = providers.length === 1 + ? PROVIDER_LABEL[providers[0]] + : `${providers.length} ${t("routes", "routes")}` + return ( + + {label} + + ) + } + if (status === "unknown") { + return ( + + {t("tryQuote", "try quote")} + + ) + } + // unsupported_chain or unsupported_token + return ( + + {t("unavailable", "unavailable")} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/Bip85VaultDialog.tsx b/projects/keepkey-vault/src/mainview/components/Bip85VaultDialog.tsx index 2aae2f43..8714bd80 100644 --- a/projects/keepkey-vault/src/mainview/components/Bip85VaultDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/Bip85VaultDialog.tsx @@ -161,8 +161,8 @@ export function Bip85VaultDialog({ onClose }: Bip85VaultDialogProps) { {/* Header */} - - + + @@ -182,7 +182,7 @@ export function Bip85VaultDialog({ onClose }: Bip85VaultDialogProps) { {/* Warning */} - + {t("bip85.warning")} @@ -194,8 +194,8 @@ export function Bip85VaultDialog({ onClose }: Bip85VaultDialogProps) { direction="column" align="center" justify="center" p="4" bg="rgba(0,0,0,0.3)" borderRadius="md" gap="3" > - - + + @@ -340,7 +340,7 @@ export function Bip85VaultDialog({ onClose }: Bip85VaultDialogProps) { key={key} align="center" justify="space-between" bg={ isJustCreated ? "rgba(74,222,128,0.12)" - : isActive ? "rgba(192,168,96,0.08)" + : isActive ? "rgba(233,196,106,0.08)" : "rgba(255,255,255,0.03)" } border="1px solid" @@ -351,19 +351,19 @@ export function Bip85VaultDialog({ onClose }: Bip85VaultDialogProps) { } borderRadius="lg" px="3" py="2.5" cursor="pointer" - _hover={{ borderColor: "kk.gold", bg: "rgba(192,168,96,0.05)" }} + _hover={{ borderColor: "kk.gold", bg: "rgba(233,196,106,0.05)" }} transition="all 0.8s ease-out" boxShadow={isJustCreated ? "0 0 12px rgba(74,222,128,0.25)" : "none"} onClick={() => handleView(seed)} > - {seed.label || `Seed #${seed.index}`} {isJustCreated && ( - SAVED diff --git a/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx b/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx index 1226dba7..b4e67363 100644 --- a/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx +++ b/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx @@ -70,7 +70,7 @@ export function BtcXpubSelector({ btcAccounts, onSelectXpub, onAddAccount, addin key={st.scriptType} as="button" onClick={() => onSelectXpub(activeAccount.accountIndex, st.scriptType)} - bg={isSelected ? "rgba(255,215,0,0.12)" : "rgba(255,255,255,0.03)"} + bg={isSelected ? "rgba(233,196,106,0.12)" : "rgba(255,255,255,0.03)"} border="1px solid" borderColor={isSelected ? "kk.gold" : "kk.border"} borderRadius="lg" @@ -78,7 +78,7 @@ export function BtcXpubSelector({ btcAccounts, onSelectXpub, onAddAccount, addin py="1.5" cursor="pointer" transition="all 0.15s" - _hover={{ borderColor: "kk.gold", bg: "rgba(255,215,0,0.06)" }} + _hover={{ borderColor: "kk.gold", bg: "rgba(233,196,106,0.06)" }} flex="1" minW="0" > diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 44b2ede1..90e2d43e 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -65,16 +65,248 @@ class AssetPageErrorBoundary extends Component< const DASHBOARD_ANIMATIONS = ` @keyframes pulseGold { - 0%, 100% { box-shadow: 0 0 12px rgba(192,168,96,0.4); } - 50% { box-shadow: 0 0 24px rgba(192,168,96,0.7); } + 0%, 100% { box-shadow: 0 0 12px rgba(233,196,106,0.4); } + 50% { box-shadow: 0 0 24px rgba(233,196,106,0.7); } } @keyframes glowCta { - 0% { box-shadow: 0 0 8px rgba(192,168,96,0.3), 0 0 20px rgba(192,168,96,0.1); } - 50% { box-shadow: 0 0 16px rgba(192,168,96,0.5), 0 0 40px rgba(192,168,96,0.2); } - 100% { box-shadow: 0 0 8px rgba(192,168,96,0.3), 0 0 20px rgba(192,168,96,0.1); } + 0% { box-shadow: 0 0 8px rgba(233,196,106,0.3), 0 0 20px rgba(233,196,106,0.1); } + 50% { box-shadow: 0 0 16px rgba(233,196,106,0.5), 0 0 40px rgba(233,196,106,0.2); } + 100% { box-shadow: 0 0 8px rgba(233,196,106,0.3), 0 0 20px rgba(233,196,106,0.1); } + } + @keyframes v3-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } ` +/* localStorage key for user's preferred portfolio view. */ +const DASHBOARD_VIEW_KEY = 'keepkey.dashboard.view' +type DashboardView = 'orbital' | 'donut' +function readSavedView(): DashboardView { + try { + const v = localStorage.getItem(DASHBOARD_VIEW_KEY) + return v === 'donut' ? 'donut' : 'orbital' + } catch { return 'orbital' } +} + +/** Orbital portfolio view — chain logos placed on a slowly rotating ring + * around a center total. Ported from the design handoff (balances.jsx + * OrbitalView) with vault tokens. Logos sized by sqrt(usd) so a + * $10k chain isn't 1000× the diameter of a $10 chain — the ring still + * reads even when one wallet dominates. */ +function OrbitalView({ + chains, + balances, + cleanBalanceUsd, + totalUsd, + totalDollars, + totalCents, + cleanTokenTotal, + onSelect, +}: { + chains: ChainDef[] + balances: Map + cleanBalanceUsd: Map + totalUsd: number + totalDollars: number + totalCents: string + cleanTokenTotal: number + onSelect: (c: ChainDef) => void +}) { + const [hover, setHover] = useState(null) + const [size, setSize] = useState(440) + + useEffect(() => { + const compute = () => setSize(Math.min(440, Math.max(280, window.innerWidth - 80))) + compute() + window.addEventListener('resize', compute) + return () => window.removeEventListener('resize', compute) + }, []) + + const cx = size / 2 + const cy = size / 2 + const orbitR = size * 0.42 + const ringR = size * 0.46 + + const orbitChains = chains + .map(c => ({ chain: c, usd: cleanBalanceUsd.get(c.id)?.usd || 0, bal: balances.get(c.id) })) + .filter(x => x.usd > 0) + .slice(0, 8) + + return ( + + + + + + + + + + + + + + {/* Center total */} + + + Total + + + + ${totalDollars.toLocaleString()} + + + .{totalCents} + + + + {chains.length} CHAINS + {cleanTokenTotal > 0 && ` · ${cleanTokenTotal} ASSETS`} + + + + {/* Satellite chains */} + {orbitChains.map(({ chain, usd, bal }, i) => { + const angle = (Math.PI * 2 * i) / orbitChains.length - Math.PI / 2 + const x = cx + Math.cos(angle) * orbitR + const y = cy + Math.sin(angle) * orbitR + const sat = Math.max(40, Math.min(72, 30 + Math.sqrt(usd) * 1.4)) + const isHover = hover === chain.id + const pct = totalUsd > 0 ? (usd / totalUsd) * 100 : 0 + return ( + setHover(chain.id)} + onMouseLeave={() => setHover(null)} + onClick={() => onSelect(chain)} + position="absolute" + left={`${x - sat / 2}px`} + top={`${y - sat / 2}px`} + w={`${sat}px`} + h={`${sat}px`} + borderRadius="full" + bg="transparent" + border="0" + p={0} + display="grid" + placeItems="center" + transition="all 0.3s cubic-bezier(0.2,0.8,0.2,1)" + transform={isHover ? 'scale(1.12)' : 'scale(1)'} + filter={isHover + ? `drop-shadow(0 0 24px ${chain.color})` + : 'drop-shadow(0 4px 14px rgba(0,0,0,0.55))'} + zIndex={isHover ? 10 : 1} + cursor="pointer" + aria-label={chain.coin} + > + + {(bal?.tokens?.length ?? 0) > 0 && ( + + +{bal!.tokens!.length} + + )} + {isHover && ( + + + {chain.coin} + + + ${usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {totalUsd > 0 && ` · ${pct.toFixed(1)}%`} + + + )} + + ) + })} + + ) +} + interface PioneerError { message: string url: string @@ -95,6 +327,8 @@ interface DashboardProps { isHiddenWallet?: boolean } +const PIONEER_ERROR_GRACE_MS = 5 * 60 * 1000 + /** Format a timestamp as a relative "time ago" string (i18n-aware) */ function formatTimeAgo(ts: number, t: (key: string, opts?: Record) => string): string { const diff = Date.now() - ts @@ -122,15 +356,65 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin const [zcashEnabled, setZcashEnabled] = useState(false) const [pioneerError, setPioneerError] = useState(null) const [cacheUpdatedAt, setCacheUpdatedAt] = useState(null) - const [tokenWarning, setTokenWarning] = useState(false) const [hasEverRefreshed, setHasEverRefreshed] = useState(false) const [visibilityMap, setVisibilityMap] = useState>({}) + const pioneerErrorFirstSeenRef = useRef(null) + const pioneerErrorTimerRef = useRef | null>(null) + const hasUsableBalanceSnapshot = balances.size > 0 || cacheUpdatedAt !== null + + const clearPioneerError = useCallback(() => { + pioneerErrorFirstSeenRef.current = null + if (pioneerErrorTimerRef.current) { + clearTimeout(pioneerErrorTimerRef.current) + pioneerErrorTimerRef.current = null + } + setPioneerError(null) + }, []) + + const stagePioneerError = useCallback((error: PioneerError) => { + if (!hasUsableBalanceSnapshot) { + pioneerErrorFirstSeenRef.current = Date.now() + if (pioneerErrorTimerRef.current) { + clearTimeout(pioneerErrorTimerRef.current) + pioneerErrorTimerRef.current = null + } + setPioneerError(error) + return + } + if (!pioneerErrorFirstSeenRef.current) pioneerErrorFirstSeenRef.current = Date.now() + const elapsed = Date.now() - pioneerErrorFirstSeenRef.current + if (elapsed >= PIONEER_ERROR_GRACE_MS) { + setPioneerError(error) + return + } + if (!pioneerErrorTimerRef.current) { + pioneerErrorTimerRef.current = setTimeout(() => { + pioneerErrorTimerRef.current = null + setPioneerError(error) + }, PIONEER_ERROR_GRACE_MS - elapsed) + } + }, [hasUsableBalanceSnapshot]) - // Load token visibility overrides (for spam filtering) useEffect(() => { - rpcRequest>('getTokenVisibilityMap', undefined, 5000) - .then(setVisibilityMap) - .catch(() => {}) + return () => { + if (pioneerErrorTimerRef.current) clearTimeout(pioneerErrorTimerRef.current) + } + }, []) + + // Load token visibility overrides (for spam filtering). Refetch on + // `token-visibility-changed` push so a "mark as scam" action in + // AssetPage immediately removes the spam USD from the dashboard total + // (and a "mark as safe" puts it back). Without this subscription, the + // initial on-mount snapshot would persist and the dashboard would keep + // showing the spam balance until full reload. + useEffect(() => { + const refetch = () => { + rpcRequest>('getTokenVisibilityMap', undefined, 5000) + .then(setVisibilityMap) + .catch(() => {}) + } + refetch() + return onRpcMessage('token-visibility-changed', refetch) }, []) // Load feature flags (re-check when settings change) @@ -153,9 +437,9 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin // Listen for Pioneer connection errors from backend useEffect(() => { return onRpcMessage("pioneer-error", (payload) => { - setPioneerError(payload as PioneerError) + stagePioneerError(payload as PioneerError) }) - }, []) + }, [stagePioneerError]) // Load custom chains on mount and register their explorer links useEffect(() => { @@ -289,14 +573,13 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin }) // runs on every render but ref-gated to fire once // Manual refresh: fetch live data from Pioneer API - const refreshBalances = useCallback(async () => { + // forceRefresh=true bypasses Pioneer's balance cache — only pass it on explicit user action + const refreshBalances = useCallback(async (forceRefresh = false) => { if (loadingBalances || watchOnly) return setLoadingBalances(true) - setPioneerError(null) - setTokenWarning(false) try { - const result = await rpcRequest('getBalances', undefined, 120000) + const result = await rpcRequest('getBalances', { forceRefresh }, 200000) if (result) { const tokenTotal = result.reduce((n, b) => n + (b.tokens?.length || 0), 0) const balTotal = result.reduce((n, b) => n + (b.balanceUsd || 0), 0) @@ -310,29 +593,50 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin } } } - const map = new Map() - for (const b of result) map.set(b.chainId, b) + // No-walk-backwards merge: start from current displayed balances so chains + // from failed Pioneer chunks (which are absent from `result`) stay visible. + // Only update a chain if the new value is non-zero, or if we had no prior data. + const map = new Map(balances) + for (const b of result) { + const prev = map.get(b.chainId) + if (!prev || b.balanceUsd > 0 || parseFloat(b.balance || '0') > 0) { + map.set(b.chainId, b) + } else { + console.log(`[Dashboard] Preserving prior ${b.chainId} balance — Pioneer returned 0`) + } + } setBalances(map) setCacheUpdatedAt(Date.now()) setHasEverRefreshed(true) - - // Warn if no token data came back (possible API issue) - if (tokenTotal === 0 && balTotal > 0) { - setTokenWarning(true) - } + clearPioneerError() } } catch (e: any) { - console.warn('[Dashboard] getBalances failed:', e.message) + const message = e?.message || 'Unable to refresh balances' + console.warn('[Dashboard] getBalances failed:', message) + stagePioneerError({ message, url: 'the configured balance server' }) } setLoadingBalances(false) - }, [loadingBalances, watchOnly]) + }, [loadingBalances, watchOnly, clearPioneerError, stagePioneerError]) + + // Auto-refresh balances when Zcash feature flag is enabled mid-session + const prevZcashRef = useRef(zcashEnabled) + useEffect(() => { + const becameEnabled = zcashEnabled && !prevZcashRef.current + if (becameEnabled && !loadingBalances) { + console.log('[Dashboard] Zcash enabled — refreshing balances') + refreshBalances() + prevZcashRef.current = true + } else if (!zcashEnabled) { + prevZcashRef.current = false + } + }, [zcashEnabled, refreshBalances, loadingBalances]) // Auto-refresh after new seed (OOB setup) — one-shot, then clear the flag useEffect(() => { if (forceRefresh && initialLoaded && !hasEverRefreshed && !loadingBalances) { console.log('[Dashboard] New seed detected — auto-refreshing balances (one-shot)') - refreshBalances() + refreshBalances(true) onForceRefreshConsumed?.() } }, [forceRefresh, initialLoaded, hasEverRefreshed, loadingBalances, refreshBalances, onForceRefreshConsumed]) @@ -407,6 +711,27 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin const hasAnyBalance = chartData.length > 0 + /* Portfolio view mode — orbital is the new default per design handoff, + * donut is preserved as a toggle so power users can still get the + * percentage-bar legend. Persisted to localStorage. */ + const [viewMode, setViewMode] = useState(readSavedView) + useEffect(() => { + try { localStorage.setItem(DASHBOARD_VIEW_KEY, viewMode) } catch { /* private mode etc. */ } + }, [viewMode]) + + /* Splits totalUsd into dollars + cents so the orbital can render the + * cents in a smaller weight (matches handoff layout). */ + const totalDollars = Math.floor(totalUsd) + const totalCents = (totalUsd % 1).toFixed(2).slice(2) || '00' + + /* cleanTokenTotal — sum of non-spam tokens across all chains. Used for + * the "N CHAINS · M ASSETS" subtitle on the orbital. */ + const cleanTokenTotal = useMemo(() => { + let n = 0 + for (const v of cleanBalanceUsd.values()) n += v.cleanTokenCount + return n + }, [cleanBalanceUsd]) + const visibleChains = useMemo(() => allChains.filter(c => { if (!isChainSupported(c, firmwareVersion)) return false // Zcash transparent is hidden by default — show when feature flag is on @@ -455,12 +780,12 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin mb="3" px="3" py="2" - bg="rgba(255,215,0,0.08)" + bg="rgba(233,196,106,0.08)" border="1px solid" - borderColor="rgba(255,215,0,0.2)" + borderColor="rgba(233,196,106,0.2)" borderRadius="lg" > - + @@ -476,25 +801,30 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin mb="3" px="4" py="3" - bg="rgba(220,53,69,0.08)" + bg="rgba(224,140,123,0.08)" border="1px solid" - borderColor="rgba(220,53,69,0.3)" + borderColor="rgba(224,140,123,0.3)" borderRadius="lg" > - + - + {t("pioneerOfflineTitle")} {t("pioneerOfflineDesc", { url: pioneerError.url })} + {pioneerError.message && ( + + {pioneerError.message} + + )} {onOpenSettings && ( { - setPioneerError(null) + clearPioneerError() onOpenSettings() }} > @@ -546,8 +876,8 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin cursor="pointer" _hover={{ color: "white" }} onClick={() => { - setPioneerError(null) - refreshBalances() + clearPioneerError() + refreshBalances(true) }} > {t("retry")} @@ -557,34 +887,8 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin )} - {/* Token warning banner — shown when refresh succeeded but no tokens returned */} - {tokenWarning && !pioneerError && ( - - - - - - - - - {t("tokenWarningTitle")} - - - - {t("tokenWarningDesc")} - - - )} - - {/* Portfolio Chart — or Welcome placeholder for empty wallets */} + {/* Portfolio view — orbital (default) or donut, switchable via the + pill toggle in the top-right of the card. */} {hasAnyBalance ? ( - - setActiveSliceIndex(i === null ? 0 : i)} + {/* View toggle — orbital / donut. Two-state pill with icon glyphs. */} + + setViewMode('orbital')} + w="26px" h="22px" + borderRadius="999px" + display="flex" alignItems="center" justifyContent="center" + bg={viewMode === 'orbital' ? 'rgba(233,196,106,0.18)' : 'transparent'} + color={viewMode === 'orbital' ? 'var(--gold)' : 'var(--text-3)'} + _hover={{ color: 'var(--text-1)' }} + transition="all 0.15s" + cursor="pointer" + title="Orbital view" + > + + + + + + + + setViewMode('donut')} + w="26px" h="22px" + borderRadius="999px" + display="flex" alignItems="center" justifyContent="center" + bg={viewMode === 'donut' ? 'rgba(233,196,106,0.18)' : 'transparent'} + color={viewMode === 'donut' ? 'var(--gold)' : 'var(--text-3)'} + _hover={{ color: 'var(--text-1)' }} + transition="all 0.15s" + cursor="pointer" + title="Donut view" + > + + + + + + + + {viewMode === 'orbital' ? ( + setSelectedChain(c)} /> - - + setActiveSliceIndex(i === null ? 0 : i)} + onHoverSlice={(i) => setActiveSliceIndex(i === null ? 0 : i)} /> - - + + setActiveSliceIndex(i === null ? 0 : i)} + /> + + + )} - ) : !loadingBalances && initialLoaded && ( + ) : !loadingBalances && initialLoaded && !pioneerError && ( {/* Shield / vault icon */} @@ -628,12 +999,12 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin w="56px" h="56px" borderRadius="full" - bg="rgba(192,168,96,0.1)" + bg="rgba(233,196,106,0.1)" display="flex" alignItems="center" justifyContent="center" > - + @@ -680,7 +1051,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin borderRadius="full" cursor="pointer" transition="all 0.2s" - _hover={{ color: "white", bg: "rgba(192,168,96,0.12)" }} + _hover={{ color: "white", bg: "rgba(233,196,106,0.12)" }} onClick={() => setShowReports(true)} > @@ -707,9 +1078,9 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin transition="all 0.2s" _hover={loadingBalances ? {} : { color: "white", - bg: "rgba(192,168,96,0.12)", + bg: "rgba(233,196,106,0.12)", }} - onClick={loadingBalances ? undefined : refreshBalances} + onClick={loadingBalances ? undefined : () => refreshBalances(true)} css={isStale && !loadingBalances ? { animation: "pulseGold 2s ease-in-out infinite" } : undefined} > @@ -727,9 +1098,9 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin ? <> { const age = Date.now() - cacheUpdatedAt - if (age < 3_600_000) return "#4ADE80" - if (age < 86_400_000) return "#FBBF24" - return "#F87171" + if (age < 3_600_000) return "var(--teal)" + if (age < 86_400_000) return "var(--gold)" + return "var(--rose)" })()}> {formatTimeAgo(cacheUpdatedAt, t)} @@ -749,34 +1120,34 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin mb="4" px="5" py="4" - bg="rgba(192,168,96,0.08)" + bg="rgba(233,196,106,0.08)" border="1px solid" - borderColor="rgba(192,168,96,0.35)" + borderColor="rgba(233,196,106,0.35)" borderRadius="xl" cursor="pointer" transition="all 0.3s ease-out" css={{ animation: "glowCta 3s ease-in-out infinite" }} _hover={{ - bg: "rgba(192,168,96,0.15)", + bg: "rgba(233,196,106,0.15)", borderColor: "kk.gold", transform: "scale(1.02)", - boxShadow: "0 0 24px rgba(192,168,96,0.5), 0 0 48px rgba(192,168,96,0.2)", + boxShadow: "0 0 24px rgba(233,196,106,0.5), 0 0 48px rgba(233,196,106,0.2)", }} _active={{ transform: "scale(0.98)", transition: "transform 0.1s" }} - onClick={refreshBalances} + onClick={() => refreshBalances(true)} > - + @@ -888,11 +1259,32 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin {usdNum > 0 && ( )} - {tokenCount > 0 && ( - - {t("tokensCount", { count: tokenCount })} - - )} + {(() => { + // Zcash gets a special "+ shielded" sub-row instead of generic token count. + // The shielded balance is appended as a synthetic token with type:'shielded' + // in getBalances; surface it explicitly so users see the private balance. + const shielded = bal.tokens?.find(tk => tk.type === 'shielded') + const otherTokens = bal.tokens?.filter(tk => tk.type !== 'shielded') ?? [] + return ( + <> + {shielded && parseFloat(shielded.balance || '0') > 0 && ( + + + + + + + {formatBalance(shielded.balance)} private + + + )} + {otherTokens.length > 0 && ( + + {t("tokensCount", { count: otherTokens.length })} + + )} + + ) + })()} ) : loadingBalances ? ( {t("loading", { ns: "common" })} @@ -916,7 +1308,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin transition="all 0.15s" _hover={{ borderColor: "kk.gold", - bg: "rgba(255,215,0,0.05)", + bg: "rgba(233,196,106,0.05)", }} onClick={() => setShowAddChain(true)} display="flex" @@ -958,8 +1350,8 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin setShowBip85(false)} /> )} - {/* BIP-85 lock icon — bottom right (only when feature enabled AND firmware >= 7.15.0) */} - {bip85Enabled && !watchOnly && firmwareVersion && versionCompare(firmwareVersion, '7.15.0') >= 0 && ( + {/* BIP-85 lock icon — bottom right (only when feature enabled AND firmware >= 7.16.0) */} + {bip85Enabled && !watchOnly && firmwareVersion && versionCompare(firmwareVersion, '7.16.0') >= 0 && ( setShowBip85(true)} zIndex={10} title="BIP-85 Seed Vault" > - + diff --git a/projects/keepkey-vault/src/mainview/components/DeviceClaimedDialog.tsx b/projects/keepkey-vault/src/mainview/components/DeviceClaimedDialog.tsx index 12b4f37b..cac889b2 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceClaimedDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceClaimedDialog.tsx @@ -11,58 +11,74 @@ export function DeviceClaimedDialog({ error }: { error: string }) { left="50%" transform="translate(-50%, -50%)" mt="60px" - bg="rgba(0, 0, 0, 0.95)" - border="2px solid" - borderColor="#D4A017" - borderRadius="xl" + bg="linear-gradient(180deg, var(--ink-2), var(--ink-1))" + border="1px solid var(--line-2)" + borderRadius="var(--r-lg)" px={8} py={7} maxW="460px" w="90%" - boxShadow="0 0 40px rgba(212, 160, 23, 0.3)" + boxShadow="var(--shadow-2)" > - + + {t("claimed.title")} - + {t("claimed.description")} - - + + {error} - + - + {t("claimed.toConnect")} - {t("claimed.step1")} - {t("claimed.step2")} - {t("claimed.step3")} + {t("claimed.step1")} + {t("claimed.step2")} + {t("claimed.step3")} - - {t("claimed.waiting")} - + + + + {t("claimed.waiting")} + + {t("claimed.supportLink")} diff --git a/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx b/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx index abda9eca..e4650f3b 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx @@ -1,7 +1,11 @@ /** - * DeviceGrid — unified grid of all registered devices + emulator wallets. - * Shown on the splash screen (center-center) when disconnected. - * Each card: label, type badge, last seen, View Portfolio / Start / Forget. + * DeviceGrid — wallet selector shown on the splash screen. + * + * Renders cards for every registered KeepKey (live or watch-only cache) plus + * any emulator wallets when that flag is on. Empty grid is now an explicit + * "Connect your KeepKey to begin" hero instead of nothing — gives first-run + * users somewhere to land. Watch-only state is called out clearly so users + * can tell at a glance which wallets have signing live and which are cached. */ import { useState, useEffect, useCallback } from "react" import { Box, Flex, Text, Image } from "@chakra-ui/react" @@ -17,55 +21,21 @@ interface DeviceGridProps { } const REVEAL_DELAY_MS = 2500 -const CHANNEL_COLORS: Record = { alpha: '#F59E0B', beta: '#3B82F6', release: '#22C55E' } - -function ChannelPicker({ name, channels, onSelect, onCancel, loading }: { - name: string - channels: { channel: string; installed: boolean }[] - onSelect: (name: string, channel: string) => void - onCancel: () => void - loading: boolean -}) { - const installed = channels.filter(c => c.installed) - return ( - - Select firmware: - - {installed.map(c => ( - onSelect(name, c.channel)} - loading={loading} - /> - ))} - - - {installed.length === 0 && ( - No firmware installed - )} - - ) -} +const SUPPORT_URL = 'https://support.keepkey.com' + let hasRevealedOnce = false // module-level: skip delay after first reveal (e.g. returning from X) export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false }: DeviceGridProps) { const [devices, setDevices] = useState([]) const [emuWallets, setEmuWallets] = useState([]) const [emuStatus, setEmuStatus] = useState(null) - const [emuPaired, setEmuPaired] = useState(false) const [loading, setLoading] = useState(null) const [confirmForget, setConfirmForget] = useState(null) const [confirmDeleteEmu, setConfirmDeleteEmu] = useState(null) - const [channelPicker, setChannelPicker] = useState(null) // emu name showing channel picker - const [emuChannels, setEmuChannels] = useState<{ channel: string; installed: boolean }[]>([]) const [error, setError] = useState(null) const [showValues, setShowValues] = useState(false) const [revealed, setRevealed] = useState(hasRevealedOnce) - // Delay reveal only on first app launch so animated logo shows during USB scan. - // On re-entry (pressing X from emu/watch-only), tiles show immediately. useEffect(() => { if (hasRevealedOnce) { setRevealed(true); return } const timer = setTimeout(() => { setRevealed(true); hasRevealedOnce = true }, REVEAL_DELAY_MS) @@ -74,8 +44,6 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } const refresh = useCallback(async () => { try { - // Skip all emulator RPCs when the feature flag is off — keeps the - // rendered grid clean of Pair/Add cards and empty wallet tiles. const [devs, status, wallets] = await Promise.all([ rpcRequest("getRegisteredDevices", undefined, 5000), emulatorEnabled @@ -86,7 +54,7 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } : Promise.resolve([] as EmulatorWalletInfo[]), ]) setDevices(devs) - if (status) { setEmuStatus(status); setEmuPaired(status.paired) } + if (status) setEmuStatus(status) setEmuWallets(wallets) setError(null) } catch (e: any) { @@ -105,15 +73,10 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } return unsub }, [refresh, emulatorEnabled]) - // When the flag is flipped off while the grid is mounted, clear any stale - // emulator state so the render-site conditionals (emuPaired, emuStatus, - // emuWallets.length) all short-circuit cleanly. useEffect(() => { if (!emulatorEnabled) { setEmuStatus(null) - setEmuPaired(false) setEmuWallets([]) - setChannelPicker(null) setConfirmDeleteEmu(null) } }, [emulatorEnabled]) @@ -131,23 +94,10 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } }, [refresh]) const handleStartEmu = useCallback(async (name: string) => { - // Fetch available channels and show picker - setError(null) - setChannelPicker(name) - try { - const ch = await rpcRequest<{ channel: string; installed: boolean }[]>("emulatorGetChannels", undefined, 5000) - setEmuChannels(ch) - } catch { - setEmuChannels([]) - } - }, []) - - const handleStartEmuWithChannel = useCallback(async (name: string, channel: string) => { - setChannelPicker(null) setLoading(`emu:${name}`) setError(null) try { - await rpcRequest("emulatorSwitchWallet", { name, channel }, 20000) + await rpcRequest("emulatorSwitchWallet", { name }, 20000) await refresh() } catch (e: any) { setError(e?.message || String(e)) } setLoading(null) @@ -172,28 +122,6 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } setConfirmDeleteEmu(null) }, [refresh]) - const handleAddEmu = useCallback(async () => { - // Generate a unique name like emu-1, emu-2, ... - const existing = new Set(emuWallets.map(w => w.name)) - let idx = 1 - while (existing.has(`emu-${idx}`)) idx++ - const name = `emu-${idx}` - // Show channel picker for the new emulator - handleStartEmu(name) - }, [emuWallets, handleStartEmu]) - - const handlePairEmu = useCallback(async () => { - setLoading("emu:__pair") - try { - const s = await rpcRequest("emulatorPair", undefined, 10000) - setEmuStatus(s) - setEmuPaired(s.paired) - await refresh() - } catch (e: any) { setError(e?.message || String(e)) } - setLoading(null) - }, [refresh]) - - // ── Helpers ────────────────────────────────────────────────────── function formatUsd(n: number): string { @@ -201,6 +129,7 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } } const grandTotal = devices.reduce((sum, d) => sum + (d.totalUsd || 0), 0) + + emuWallets.reduce((sum, w) => sum + (w.totalUsd || 0), 0) function timeAgo(ts: number): string { const diff = Date.now() - ts @@ -214,219 +143,231 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } } const emuRunning = emuStatus?.state === "running" - // Show grid if there are devices, emulator wallets, OR if the emulator - // system responded at all (so "Pair Emulator" card is reachable on clean install) - const hasContent = devices.length > 0 || emuWallets.length > 0 || emuPaired || emuStatus !== null + const hasContent = devices.length > 0 || emuWallets.length > 0 - // Notify parent when grid is ready to display useEffect(() => { - if (revealed && hasContent) onReady?.() - }, [revealed, hasContent, onReady]) + // Always notify parent once revealed — even when empty — so SplashScreen + // can slide the logo to the top and reveal the connect-prompt hero. + if (revealed) onReady?.() + }, [revealed, onReady]) - // Wait for reveal delay + content before rendering - if (!revealed || !hasContent) return null + if (!revealed) return null // ── Render ─────────────────────────────────────────────────────── return ( + {/* Header */} + + + {hasContent ? 'Workspace' : 'Welcome'} + + + {hasContent ? 'Choose a wallet' : 'Connect your KeepKey'} + + + {hasContent + ? 'Plug in your KeepKey to sign live, or open a saved wallet in view-only mode.' + : 'Plug in your device with the supplied USB cable to start a session. View-only wallets will appear here once you’ve paired a device.'} + + + {/* Error banner */} {error && ( - - {error} + + {error} )} - {/* Section label */} - - Registered Devices - - - {/* Grid of cards */} - - - {/* ── Physical device cards ─────────────────────────── */} - {devices.map((d) => { - const color = deviceIdToColor(d.deviceId) - return ( - - {/* Forget X — top right */} - {confirmForget === d.deviceId ? ( - - Forget? - handleForgetDevice(d.deviceId)} loading={loading === d.deviceId} /> - setConfirmForget(null)} /> - - ) : ( - - setConfirmForget(d.deviceId)} /> - - )} - - + {/* Empty-state hero — when there's nothing registered yet */} + {!hasContent && ( + + + + + + + + + + + - - {d.label || "KeepKey"} + + Connect a KeepKey to begin - - fw {d.firmwareVer} · {timeAgo(d.updatedAt)} + + Once paired, your wallet keys never leave the device. Vault talks to the device over the cable — no servers in between. - {d.totalUsd > 0 && ( - - {showValues ? `$${formatUsd(d.totalUsd)}` : "$ ****"} - - )} - - onViewPortfolio(d.deviceId, d.label || 'KeepKey')} /> - - - )})} - - {/* ── Emulator wallet cards ─────────────────────────── */} - {emuWallets.map((w) => { - const active = w.isActive && emuRunning - const isDeleting = confirmDeleteEmu === w.name - return ( - - - + + + )} + + {/* Grid of cards */} + {hasContent && ( + + {/* Physical device cards (watch-only when not the active live session) */} + {devices.map((d) => ( + + {/* Top row: badge + forget */} + + + {confirmForget === d.deviceId ? ( + + Forget? + handleForgetDevice(d.deviceId)} loading={loading === d.deviceId} /> + setConfirmForget(null)} /> + + ) : ( + setConfirmForget(d.deviceId)} /> + )} + + + {/* Identity */} + + - - {w.name} + + {d.label || "KeepKey"} + + + fw {d.firmwareVer} · {timeAgo(d.updatedAt)} - - - {active ? "running" : "EMULATOR"} - - {w.hasMnemonic && seed saved} - - {isDeleting ? ( - - - Delete from disk? Recovery phrase needed to re-load. + + {/* Balance */} + {d.totalUsd > 0 && ( + + + Cached balance + + + {showValues ? `$${formatUsd(d.totalUsd)}` : '$ ••••'} - - handleDeleteEmu(w.name)} loading={loading === `emu:${w.name}`} /> - setConfirmDeleteEmu(null)} /> - - ) : channelPicker === w.name ? ( - setChannelPicker(null)} - loading={loading === `emu:${w.name}`} - /> - ) : ( - - {active ? ( - - ) : ( - handleStartEmu(w.name)} loading={loading === `emu:${w.name}`} /> - )} - {!active && ( - setConfirmDeleteEmu(w.name)} /> - )} - )} + + {/* Action */} + onViewPortfolio(d.deviceId, d.label || 'KeepKey')}> + Open view-only + - ) - })} - - {/* ── Add Emulator card ─────────────────────────────── */} - {emuPaired && channelPicker && !emuWallets.some(w => w.name === channelPicker) ? ( - /* Channel picker for new emulator */ - - - - - {channelPicker} - new emulator - - - setChannelPicker(null)} - loading={loading === `emu:${channelPicker}`} - /> - - ) : emuPaired && ( - - + - {loading === "emu:__add" ? "Starting..." : "Add Emulator"} - - )} + ))} + + {/* Emulator wallet cards */} + {emuWallets.map((w) => { + const active = w.isActive && emuRunning + const isDeleting = confirmDeleteEmu === w.name + const displayName = w.label || w.name + return ( + + + + {!active && !isDeleting && ( + setConfirmDeleteEmu(w.name)} /> + )} + - {/* ── Pair Emulator card (if not paired) ──────────── */} - {!emuPaired && emuStatus && ( - - - - {loading === "emu:__pair" ? "Pairing..." : "Pair Emulator"} - - - )} - + + + + + {displayName} + + {w.label && w.label !== w.name && ( + {w.name} + )} + {w.hasMnemonic && seed saved} + + + + {(w.totalUsd ?? 0) > 0 && ( + + + Cached balance + + + {showValues ? `$${formatUsd(w.totalUsd ?? 0)}` : '$ ••••'} + + + )} + + {isDeleting ? ( + + + Delete from disk? Recovery phrase needed to re-load. + + + handleDeleteEmu(w.name)} loading={loading === `emu:${w.name}`} /> + setConfirmDeleteEmu(null)} /> + + + ) : active ? ( + + Stop emulator + + ) : ( + handleStartEmu(w.name)} loading={loading === `emu:${w.name}`}> + Start + + )} + + ) + })} + + )} {/* Grand total + eyeball toggle */} {grandTotal > 0 && ( - - - Total across all devices: - {showValues ? `$${formatUsd(grandTotal)}` : "$ ****"} + + + Total across all wallets:  + + {showValues ? `$${formatUsd(grandTotal)}` : '$ ••••'} - setShowValues(v => !v)} title={showValues ? "Hide values" : "Show values"}> {showValues ? ( - + ) : ( - + @@ -436,6 +377,25 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } )} + {/* Support / troubleshooting link */} + + + + + + + + Trouble connecting? Visit support + + + {analysis.currentFirmwareVerified === true && ( - (verified) + (verified) )} {analysis.currentFirmwareVerified === false && ( - (unverified) + (unverified) )} @@ -325,7 +427,7 @@ export function FirmwareDropZone() { )} {analysis.isDowngrade && ( - + Downgrade — older than current version )} @@ -363,20 +465,20 @@ export function FirmwareDropZone() { mx="6" mb="3" p="4" bg="rgba(229,62,62,0.1)" border="2px solid" - borderColor="#E53E3E" + borderColor="var(--rose)" borderRadius="lg" > - + - + THIS WILL WIPE THE DEVICE - + You are crossing the signed/unsigned firmware boundary. {analysis.isSigned ? <> Flashing signed (official) firmware onto a device running unsigned (developer) firmware. @@ -385,7 +487,7 @@ export function FirmwareDropZone() { {' '}This transition requires a full device wipe — all keys and settings will be permanently erased. - + Make sure you have your recovery seed backed up before proceeding. )} - + I understand this will wipe my device and I have my seed backed up @@ -426,20 +528,20 @@ export function FirmwareDropZone() { mx="6" mb="3" p="4" bg="rgba(237,137,54,0.1)" border="1px solid" - borderColor="#ED8936" + borderColor="var(--gold)" borderRadius="lg" > - + - + Developer Firmware - + This is unsigned firmware intended for developers only. It may result in device state loss or unexpected behavior. @@ -455,8 +557,8 @@ export function FirmwareDropZone() { w="18px" h="18px" borderRadius="sm" border="2px solid" - borderColor={warningAcknowledged ? "#ED8936" : "#F6AD55"} - bg={warningAcknowledged ? "#ED8936" : "transparent"} + borderColor={warningAcknowledged ? "var(--gold)" : "var(--gold-2)"} + bg={warningAcknowledged ? "var(--gold)" : "transparent"} display="flex" alignItems="center" justifyContent="center" @@ -468,7 +570,7 @@ export function FirmwareDropZone() { )} - + I understand this is developer firmware @@ -495,7 +597,7 @@ export function FirmwareDropZone() { @@ -1604,7 +1604,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress, onWordCountChang {customFwProgress?.message || t('firmware.uploadingFirmware')} - + - {t('firmware.doNotUnplug')} + {t('firmware.doNotUnplug')} )} {/* Custom firmware error */} {customFwPhase === 'error' && ( - + - {t('firmware.customFlashFailed')} + {t('firmware.customFlashFailed')} {customFwError} @@ -1778,7 +1778,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress, onWordCountChang )} {verifyingPhase === 'verifying' && ( <> - + {t('verifySeed.verifying', { defaultValue: 'Verifying...' })} - + {isEmulator ? t('verifySeed.checkingAnswers', { defaultValue: 'Checking your answers...' }) : t('verifySeed.followDevice', { defaultValue: 'Follow the prompts on your KeepKey to enter the requested words.' })} @@ -2335,17 +2335,17 @@ export function OobSetupWizard({ onComplete, onSetupInProgress, onWordCountChang )} {verifyingPhase === 'success' && ( <> - + - + {t('verifySeed.verified', { defaultValue: 'Recovery Phrase Verified!' })} - + {t('verifySeed.verifiedDetail', { defaultValue: 'Your backup is correct. Keep it safe — never share it with anyone.' })} )} - )} + + {/* Provider health dots — click to open detail dialog */} + {swapHealth && ( + setHealthDialogOpen(true)} + > + {swapHealth.integrations.map(intg => { + const dotColor = + intg.status === 'ok' ? '#22c55e' : + intg.status === 'degraded' ? '#f59e0b' : + intg.status === 'offline' ? '#ef4444' : '#6b7280' + const hoverLabel = + intg.status === 'ok' ? `${intg.label}: operational` : + intg.status === 'degraded' ? `${intg.label}: ${intg.detail || 'some pairs unavailable'}` : + intg.status === 'offline' ? `${intg.label}: unreachable` : + `${intg.label}: status unknown` + return ( + + ) + })} + + )} + {!busy && ( + + )} + {/* ── Body ────────────────────────────────────────────────── */} - + {/* Padding zeroed on the complete-swap view so the 2-column hero/details + layout reaches the modal edges and the footer can span full width. */} + {/* Loading state */} {loadingAssets && ( @@ -1132,8 +1917,8 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* Error state — Pioneer unreachable or no assets */} {!loadingAssets && assetLoadError && phase === 'input' && ( - - + + @@ -1153,56 +1938,483 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* ── SUBMITTED — live tracking with step progress ──── */} {phase === 'submitted' && txid && fromAsset && toAsset && ( - - {/* Confetti burst on completion */} - {showConfetti && } + isSwapComplete ? ( + /* ── COMPLETE: wide 2-column hero/details layout ───── + Modal widens to 1040px (see outer Box). Hero on the left + anchors the mascot at 248px inside three concentric pulse + rings; details on the right surface the headline result + and tuck tx hashes + balance deltas into collapsible + accordions so the screen reads at a glance but every + number from the old layout is still one click away. */ + + {showConfetti && } + + + + {/* ── HERO (mascot + title + slim stepper) ── + Grid with three rows (1fr auto 1fr) so the auto-row + containing mascot+title+stepper is pinned to the vertical + center regardless of right-column height. Plain flex + `justify-content: center` was visually off because the + mascot's gravity pulled the eye above the column midpoint. */} + + {/* Top spacer */} + + {/* Centered content stack */} + + {/* Mascot stage — 300px frame holds rings + 248px slot */} + + {/* concentric pulse rings — staggered animation delays */} + + + + {/* Mascot slot — clipped to a circle. Gif is sized larger than + the container and scale-zoomed so its black square margins fall + outside the round clip-path, giving a clean edge-to-edge fill. */} + + + + - {/* Status icon + title inline */} - - {isSwapComplete ? ( - - + {/* Title */} + + {t("swap", "Swap")}{" "} + + {t("completed", "completed").toLowerCase()} + + + + {/* Slim stepper pill */} + + {[t("stageInput"), t("stageProtocol"), t("stageOutput")].map((name, i, arr) => ( + + + + + + + + {name} + + {i < arr.length - 1 && ( + + )} + + ))} + - ) : isSwapFailed ? ( - - + {/* Bottom spacer */} + - ) : ( - - - - + + {/* ── DETAILS (result card + accordions) ── */} + + {/* Headline result card */} + + + + + {t("youReceived", "You received")} + + + + ~{quote?.expectedOutput ? formatBalance(quote.expectedOutput) : '—'} + + {toAsset.symbol} + + {hasToPrice && quote?.expectedOutput && ( + + ≈ {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + {hasFromPrice && (() => { + const sentUsd = parseFloat(displayAmount || '0') * fromPriceUsd + const recvUsd = parseFloat(quote.expectedOutput) * toPriceUsd + const net = recvUsd - sentUsd + if (!Number.isFinite(net) || Math.abs(net) < 0.005) return null + return ` · ${t("netVsSend", "net")} ${net >= 0 ? '+' : '−'}${fmtCompact(Math.abs(net))} ${t("vsSend", "vs send")}` + })()} + + )} + + {/* Sent row */} + + + {t("sent", "Sent")} + + + + {displayAmount} {fromAsset.symbol} + + → {toAsset.symbol} + + {hasFromPrice && ( + + {fmtCompact(parseFloat(displayAmount || '0') * fromPriceUsd)} + + )} + + + + {/* Transaction details accordion */} + + + + {t("transactionDetails", "Transaction details")} + + + {liveOutboundTxid ? '2 hashes' : '1 hash'} + {(() => { + const protoHint = liveSwapper || quote?.swapper || quote?.integration + const tracker = providerTrackerUrl(protoHint, txid, { relayRequestId: liveRelayRequestId }) + return tracker ? · tracker : null + })()} + + + + + + + {/* Input tx */} + + + {t("inputTx", "Input Tx")} + + + {txid} + + + + {(() => { + const url = getExplorerTxUrl(fromAsset.chainId, txid) + return url ? ( + + ) : null + })()} + + + {/* Output tx */} + {liveOutboundTxid && ( + + + {t("outputTx", "Output Tx")} + + + {liveOutboundTxid} + + + + {(() => { + const url = getExplorerTxUrl(toAsset.chainId, liveOutboundTxid) + return url ? ( + + ) : null + })()} + + + )} + {/* Tracker row */} + {(() => { + const protoHint = liveSwapper || quote?.swapper || quote?.integration + const tracker = providerTrackerUrl(protoHint, txid, { relayRequestId: liveRelayRequestId }) + return tracker ? ( + + + {t("tracker", "Tracker")} + + + {t("trackerHint", "End-to-end status & quote breakdown")} + + + + ) : null + })()} + + + + {/* Balance changes accordion */} + {(beforeFromBal || beforeToBal) && ( + + + + {t("balanceChanges", "Balance changes")} + + + {hasFromPrice && afterFromBal && beforeFromBal && (() => { + const d = (parseFloat(afterFromBal) - parseFloat(beforeFromBal)) * fromPriceUsd + return ( + <> + {fromAsset.symbol} + + {d >= 0 ? '+' : '−'}{fmtCompact(Math.abs(d))} + + {hasToPrice && afterToBal && beforeToBal && ( + · + )} + + ) + })()} + {hasToPrice && afterToBal && beforeToBal && (() => { + const d = (parseFloat(afterToBal) - parseFloat(beforeToBal)) * toPriceUsd + return ( + <> + {toAsset.symbol} + + {d >= 0 ? '+' : '−'}{fmtCompact(Math.abs(d))} + + + ) + })()} + + + + + + + {/* From asset balance row */} + + + + {fromAsset.symbol} + + + {beforeFromBal ? formatBalance(beforeFromBal) : '—'} + + {afterFromBal ? formatBalance(afterFromBal) : '…'} + + + {afterFromBal && beforeFromBal && ( + + {formatBalance((parseFloat(afterFromBal) - parseFloat(beforeFromBal)).toFixed(8))} + + )} + {hasFromPrice && afterFromBal && beforeFromBal && ( + + {fmtCompact((parseFloat(afterFromBal) - parseFloat(beforeFromBal)) * fromPriceUsd)} + + )} + + + {/* To asset balance row */} + + + + {toAsset.symbol} + + + {beforeToBal ? formatBalance(beforeToBal) : '—'} + + {afterToBal ? formatBalance(afterToBal) : '…'} + + + {afterToBal && beforeToBal && ( + + +{formatBalance((parseFloat(afterToBal) - parseFloat(beforeToBal)).toFixed(8))} + + )} + {hasToPrice && afterToBal && beforeToBal && ( + + +{fmtCompact((parseFloat(afterToBal) - parseFloat(beforeToBal)) * toPriceUsd)} + + )} + + + + + )} - )} - - + + + {/* Footer — spans full modal width */} + + + + + + ) : ( + + {/* Confetti burst on completion */} + {showConfetti && } + + {/* Title row + provider chip */} + + + {isSwapComplete ? t("swapCompleted") : isSwapFailed ? t("swapFailed") : t("swapSubmitted")} {!isSwapComplete && !isSwapFailed && ( - {t("waitingForConfirmations")} + {t("waitingForConfirmations")} )} + + + - {/* ── Progress bar with checkpoints ────────────────────── */} - + {/* ── Progress bar with hero animation + checkpoints ──── */} + + {/* Hero animation — embedded inside the bar component so the + submitted view stays compact and the bar isn't clipped by + a large halo above. shiftingGif covers all in-flight steps + (input pending, protocol confirming, output detecting); + completedGif takes over only when the swap settles. */} + + + + + + {/* Bar track + filled portion */} {/* Filled bar — width based on step progress, animated stripes when in progress */} 0 ? 'linear-gradient(135deg, #10B981, #059669)' : 'linear-gradient(135deg, #14B8A6, #0D9488)'} - border="2px solid" borderColor={swapStep > 0 ? '#10B981' : '#14B8A6'} - boxShadow={swapStep === 0 ? '0 4px 14px rgba(20,184,166,0.5), 0 0 25px 5px rgba(20,184,166,0.3)' : '0 2px 8px rgba(16,185,129,0.4)'} + bg={swapStep > 0 ? 'linear-gradient(135deg, var(--teal-2), var(--teal))' : 'linear-gradient(135deg, var(--teal), var(--teal))'} + border="2px solid" borderColor={swapStep > 0 ? 'var(--teal-2)' : 'var(--teal)'} + boxShadow={swapStep === 0 ? '0 4px 14px rgba(139,227,196,0.5), 0 0 25px 5px rgba(139,227,196,0.32)' : '0 2px 8px rgba(168,239,210,0.4)'} style={swapStep === 0 ? { animation: 'kkSwapPulse 2s ease-in-out infinite' } : {}}> {swapStep > 0 ? ( @@ -1226,9 +2438,9 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* Checkpoint 1: Protocol — center */} 1 ? 'linear-gradient(135deg, #10B981, #059669)' : swapStep === 1 ? 'linear-gradient(135deg, #14B8A6, #0D9488)' : 'linear-gradient(135deg, #374151, #1F2937)'} - border="2px solid" borderColor={swapStep > 1 ? '#10B981' : swapStep === 1 ? '#14B8A6' : '#4B5563'} - boxShadow={swapStep === 1 ? '0 4px 14px rgba(20,184,166,0.5), 0 0 25px 5px rgba(20,184,166,0.3)' : swapStep > 1 ? '0 2px 8px rgba(16,185,129,0.4)' : '0 2px 6px rgba(75,85,99,0.3)'} + bg={swapStep > 1 ? 'linear-gradient(135deg, var(--teal-2), var(--teal))' : swapStep === 1 ? 'linear-gradient(135deg, var(--teal), var(--teal))' : 'linear-gradient(135deg, #374151, #1F2937)'} + border="2px solid" borderColor={swapStep > 1 ? 'var(--teal-2)' : swapStep === 1 ? 'var(--teal)' : '#4B5563'} + boxShadow={swapStep === 1 ? '0 4px 14px rgba(139,227,196,0.5), 0 0 25px 5px rgba(139,227,196,0.32)' : swapStep > 1 ? '0 2px 8px rgba(168,239,210,0.4)' : '0 2px 6px rgba(75,85,99,0.3)'} style={swapStep === 1 ? { animation: 'kkSwapPulse 2s ease-in-out infinite' } : {}}> {swapStep > 1 ? ( @@ -1241,9 +2453,9 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* Checkpoint 2: Output — right */} 2 ? 'linear-gradient(135deg, #10B981, #059669)' : swapStep === 2 ? 'linear-gradient(135deg, #14B8A6, #0D9488)' : 'linear-gradient(135deg, #374151, #1F2937)'} - border="2px solid" borderColor={swapStep > 2 ? '#10B981' : swapStep === 2 ? '#14B8A6' : '#4B5563'} - boxShadow={swapStep === 2 ? '0 4px 14px rgba(20,184,166,0.5), 0 0 25px 5px rgba(20,184,166,0.3)' : swapStep > 2 ? '0 2px 8px rgba(16,185,129,0.4)' : '0 2px 6px rgba(75,85,99,0.3)'} + bg={swapStep > 2 ? 'linear-gradient(135deg, var(--teal-2), var(--teal))' : swapStep === 2 ? 'linear-gradient(135deg, var(--teal), var(--teal))' : 'linear-gradient(135deg, #374151, #1F2937)'} + border="2px solid" borderColor={swapStep > 2 ? 'var(--teal-2)' : swapStep === 2 ? 'var(--teal)' : '#4B5563'} + boxShadow={swapStep === 2 ? '0 4px 14px rgba(139,227,196,0.5), 0 0 25px 5px rgba(139,227,196,0.32)' : swapStep > 2 ? '0 2px 8px rgba(168,239,210,0.4)' : '0 2px 6px rgba(75,85,99,0.3)'} style={swapStep === 2 ? { animation: 'kkSwapPulse 2s ease-in-out infinite' } : {}}> {swapStep > 2 ? ( @@ -1259,24 +2471,24 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap = 0 ? 'kk.textPrimary' : 'kk.textMuted'}>{t("stageInput")} {swapStep === 0 && liveConfirmations > 0 && ( - {liveConfirmations} {t("confirmations")} + {liveConfirmations} {t("confirmations")} )} - {swapStep > 0 && {t("statusCompleted")}} + {swapStep > 0 && {t("statusCompleted")}} = 1 ? 'kk.textPrimary' : 'kk.textMuted'}>{t("stageProtocol")} - {swapStep === 1 && {t("statusConfirming")}...} - {swapStep > 1 && {t("statusCompleted")}} + {swapStep === 1 && {t("statusConfirming")}...} + {swapStep > 1 && {t("statusCompleted")}} = 2 ? 'kk.textPrimary' : 'kk.textMuted'}>{t("stageOutput")} {swapStep === 2 && liveOutboundConfirmations !== undefined && ( - {liveOutboundConfirmations}{liveOutboundRequired ? `/${liveOutboundRequired}` : ''} + {liveOutboundConfirmations}{liveOutboundRequired ? `/${liveOutboundRequired}` : ''} )} {swapStep === 2 && liveOutboundConfirmations === undefined && ( - {t("statusOutputDetected")} + {t("statusOutputDetected")} )} - {swapStep > 2 && {t("statusCompleted")}} + {swapStep > 2 && {t("statusCompleted")}} @@ -1284,12 +2496,12 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* Live countdown — only show when not complete */} {!isSwapComplete && !isSwapFailed && (countdown > 0 || (quote?.estimatedTime && quote.estimatedTime > 0)) && ( - + - + {countdown > 0 ? ( <>{Math.floor(countdown / 60)}:{(countdown % 60).toString().padStart(2, '0')} ) : formatTime(quote?.estimatedTime || 0)} @@ -1299,24 +2511,26 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap )} {/* Amount summary */} - - + + + {displayAmount} {fromAsset.symbol} {hasFromPrice && ( {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} )} - + - - ~ + + + + ~ {hasToPrice && quote?.expectedOutput && ( {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} @@ -1346,36 +2560,51 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap ) : null })()} - + {(() => { + // Prefer the tracker-detected swapper (Pioneer's authoritative + // post-broadcast value) over the quote-time parse, which often + // misses `swapper` for aggregator routes. + const protoHint = liveSwapper || quote?.swapper || quote?.integration + const tracker = providerTrackerUrl(protoHint, txid, { relayRequestId: liveRelayRequestId }) + if (!tracker) return null + return ( + + ) + })()} {/* Outbound Txid — shown when THORChain sends the output */} {liveOutboundTxid && ( - + - {t("stageOutput")} - + {t("stageOutput")} + {liveOutboundTxid} - {(() => { - const url = getExplorerTxUrl(toAsset.chainId, liveOutboundTxid) + // For refunds the outbound is on the SOURCE chain — the + // tracker fills `liveOutboundChainId` from Midgard's + // action.out asset. Fall back to toAsset only when the + // classifier hasn't run yet (older history records). + const outChainId = liveOutboundChainId || toAsset.chainId + const url = getExplorerTxUrl(outChainId, liveOutboundTxid) return url ? ( - @@ -1389,14 +2618,14 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* Before / After balance comparison — shown on completion */} {isSwapComplete && (beforeFromBal || beforeToBal) && ( - + Balance Changes {/* From asset balance change */} - + {fromAsset.symbol} @@ -1405,17 +2634,17 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {beforeFromBal ? formatBalance(beforeFromBal) : '-'} - + {afterFromBal ? formatBalance(afterFromBal) : '...'} {afterFromBal && beforeFromBal && ( - + ({formatBalance((parseFloat(afterFromBal) - parseFloat(beforeFromBal)).toFixed(8))}) )} {hasFromPrice && afterFromBal && beforeFromBal && ( - + {fmtCompact((parseFloat(afterFromBal) - parseFloat(beforeFromBal)) * fromPriceUsd)} )} @@ -1424,7 +2653,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* To asset balance change */} - + {toAsset.symbol} @@ -1433,17 +2662,17 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {beforeToBal ? formatBalance(beforeToBal) : '-'} - - {afterToBal ? : '...'} + + {afterToBal ? : '...'} {afterToBal && beforeToBal && ( - + (+{formatBalance((parseFloat(afterToBal) - parseFloat(beforeToBal)).toFixed(8))}) )} {hasToPrice && afterToBal && beforeToBal && ( - + +{fmtCompact((parseFloat(afterToBal) - parseFloat(beforeToBal)) * toPriceUsd)} )} @@ -1461,264 +2690,602 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {t("newSwap")} + ) )} {/* ── SIGNING / APPROVING / BROADCASTING ───────────────── */} + {/* ── AWAITING DEVICE — dedicated full-screen confirm + Per handoff design: 220px KeepKey gif, prominent headline, + concise instruction, then a single summary chip. The + substage detail moves into the chip area as small mono text + instead of competing with the device illustration. */} {busy && fromAsset && toAsset && ( - - {/* Device icon with label inline */} - - - - - - + + {/* Cancel/X — only meaningful while the device is awaiting a + button press (signing/approving). Once the user has confirmed + on device and we're broadcasting, the tx is already going + to the network and there's no unwind. */} + {(phase === 'signing' || phase === 'approving') && ( + + - - - {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} - - ~ - - {hasFromPrice && ( - - {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} - {hasToPrice && quote?.expectedOutput ? ` \u2192 ${fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)}` : ''} + {/* Big device illustration — 6-face CSS-3D KeepKey rotating + around its Y axis. The OLED face mirrors the swap pair the + user is being asked to confirm so the device shown matches + the action requested. */} + + +
+
+ {(subStage === 'approve-signing' || phase === 'approving') ? 'APPROVE' : 'CONFIRM SWAP'} +
+
+ {displayAmount} {fromAsset.symbol} +
+
+ → ~{quote?.expectedOutput ? formatBalance(quote.expectedOutput) : '—'} {toAsset.symbol} +
+
+ {(quote?.swapper || quote?.integration || '').toString().toUpperCase()} +
+
+
+ ▶ +
+ + } + /> +
+ + {/* Headline + secondary instruction */} + + + + {subStage === 'approve-signing' ? t("approveOnDevice", "Approve on device") + : subStage === 'approve-broadcasting' ? t("approvalBroadcasting", "Broadcasting approval…") + : subStage === 'approve-waiting-receipt' ? t("approvalWaiting", "Waiting for approval to confirm…") + : subStage === 'swap-signing' ? t("confirmOnDevice") + : subStage === 'swap-broadcasting' ? t("broadcasting") + : phase === 'approving' ? t("approvingToken") + : phase === 'signing' ? t("confirmOnDevice") + : t("broadcasting")} - )} + {fromAsset?.contractAddress && ( + + + {subStage?.startsWith('approve-') ? '1/2' + : subStage?.startsWith('swap-') ? '2/2' + : phase === 'approving' ? '1/2' + : '2/2'} + + + )} + + + {subStage === 'approve-signing' ? t("approvalRequired") + : subStage === 'approve-broadcasting' ? t("approvalBroadcastingDesc", "Submitting the approval to the network…") + : subStage === 'approve-waiting-receipt' ? t("approvalWaitingDesc", "Waiting for the approval to mine before we can sign the swap.") + : subStage === 'swap-signing' ? t("confirmOnDeviceDesc") + : subStage === 'swap-broadcasting' ? t("broadcastingDesc") + : phase === 'signing' ? t("confirmOnDeviceDesc") + : phase === 'approving' ? t("approvalRequired") + : t("broadcastingDesc")} + + + {/* Summary chip — single inline pill, mono numbers */} + + + {displayAmount} {fromAsset.symbol} + + + + + + + + ~ + + + {hasFromPrice && ( + + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + {hasToPrice && quote?.expectedOutput ? ` → ${fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)}` : ''} + + )}
)} - {/* ── REVIEW ───────────────────────────────────────────── */} + {/* ── REVIEW (confirm quote) ──────────────────────────── */} {phase === 'review' && quote && fromAsset && toAsset && !busy && ( - - {/* You Send / You Receive */} - - {t("youSend")} - - - - {displayAmount} {fromAsset.symbol} - - {fromAsset.name} - {hasFromPrice && ( - {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} - )} - + + {/* Combined send/receive — single tight card with from → to */} + + + + + {displayAmount} {fromAsset.symbol} + {hasFromPrice && ( + {fmtCompact(parseFloat(displayAmount) * fromPriceUsd)} + )} + + + + + + + ~ + {hasToPrice && ( + {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + )} + + + {isFeeReservedNativeMax && ( + {t("sendMaxGasNote")} + )} - - - - - + {/* Animated route map — gold dot travels from-token → + integration → to-token. Shows the actual swap topology + (not just a pill). Integration label uses Pioneer's + authoritative swapper name when available. */} + {(() => { + const info = resolveProvider(quote.swapper || quote.integration) + const integrationName = quote.swapper || quote.integration || info.name + const fromColor = CHAINS.find(c => c.id === fromAsset.chainId)?.color + const toColor = CHAINS.find(c => c.id === toAsset.chainId)?.color + return ( + + + + {t("route", "Route")} + + + + + + ) + })()} + + {/* Key quote numbers — always visible, condensed */} + + + + 1 {fromAsset.symbol} = {formatBalance((parseFloat(quote.expectedOutput) / parseFloat(displayAmount || '1')).toString())} {toAsset.symbol} + + + {formatBalance(quote.expectedOutput)} {toAsset.symbol}{hasToPrice ? ` (${fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)})` : ''} + + + {formatBalance(quote.minimumOutput)} {toAsset.symbol}{hasToPrice ? ` (${fmtCompact(parseFloat(quote.minimumOutput) * toPriceUsd)})` : ''} + + + {formatQuoteAssetAmount(quote.fees.outbound, toAsset, quote.expectedOutput)} {toAsset.symbol} ({(quote.fees.totalBps / 100).toFixed(2)}%) + + + {(slippageBps / 100).toFixed(2)}% max + + + {(quote.slippageBps / 100).toFixed(2)}% + + + {formatTime(quote.estimatedTime)} + + + + + {fromAsset.chainFamily === 'evm' && ( + + + + {t("evmSummary", "EVM summary")} + + + {previewLoading + ? t("payloadBuilding", "building payload") + : previewError + ? t("payloadFailed", "payload failed") + : auditPayloadReady + ? t("payloadReady", "payload ready") + : t("payloadRequiredShort", "payload required")} + + + + {previewLoading && ( + {t("buildingPayloadDetail", "Building the exact transaction payload for review...")} + )} + {previewError && ( + {t("previewFailed", "Build preview failed")}: {previewError} + )} + {previewBuild?.approveTx && ( + + )} + {previewBuild?.unsignedTx && ( + + )} + - + )} - - {t("youReceive")} - - - - ~ - - {toAsset.name} - {hasToPrice && ( - {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + {/* Collapsible details: vault/router/memo + hdwallet payload audit */} + + + {showDetails && ( + + + {quote.router && fromAsset.chainFamily === 'evm' && ( + + {t("routerContract")} + {quote.router} + )} - + + {t("vault")} + {quote.inboundAddress} + + {quote.memo && ( + + memo + {quote.memo} + + )} + + {/* Hdwallet payload — built ahead of time via previewSwapBuild + so the user can audit the exact tx going to the device. */} + + {previewLoading && ( + Building transaction preview… + )} + {previewError && ( + Preview failed: {previewError} + )} + {previewBuild?.approveTx && ( + + Approve tx (ERC-20 allowance) + + + {JSON.stringify(previewBuild.approveTx, null, 2)} + + + + )} + {previewBuild?.unsignedTx && ( + + + {previewBuild.approveTx ? 'Swap tx (sent after approval)' : 'Hdwallet payload'} + + + + {JSON.stringify(previewBuild.unsignedTx, null, 2)} + + + + )} + + - - {isNativeEvmMax && ( - {t("sendMaxGasNote")} )} - {/* Quote details */} - - - - {t("rate")} - - - 1 {fromAsset.symbol} = {formatBalance( - (parseFloat(quote.expectedOutput) / parseFloat(displayAmount || '1')).toString() - )} {toAsset.symbol} + {/* Preview build error — most likely insufficient gas/balance, surface upfront */} + {previewError && ( + + + {t("previewFailed", "Build preview failed")}: {previewError} + + + )} + + {/* On-chain balance check — hard block. USDT (and most ERC-20s) + revert with INVALID opcode when transferFrom > balance, so we + refuse to sign rather than waste gas on a guaranteed revert. */} + {previewBuild?.balance && !previewBuild.balance.sufficient && (() => { + const b = previewBuild.balance! + const tokenDecimals = normalizeDecimals(fromAsset.decimals) + const fmt = (raw: string) => { + if (tokenDecimals === null) return raw + try { + const n = Number(BigInt(raw)) / Math.pow(10, tokenDecimals) + return n.toLocaleString(undefined, { maximumFractionDigits: 8 }) + } catch { return raw } + } + return ( + + + + + + + {t("insufficientBalance", "Insufficient {{symbol}} balance", { symbol: fromAsset.symbol })} - {hasFromPrice && ( - - 1 {fromAsset.symbol} = {fmtCompact(fromPriceUsd)} - - )} - - - - {t("minimumReceived")} - - - {formatBalance(quote.minimumOutput)} {toAsset.symbol} + + {t("insufficientBalanceDetail", "You have {{have}} {{symbol}} but the swap needs {{need}} {{symbol}}. The transferFrom would revert on-chain.", { have: fmt(b.current), need: fmt(b.required), symbol: fromAsset.symbol })} - {hasToPrice && ( - - {fmtCompact(parseFloat(quote.minimumOutput) * toPriceUsd)} - - )} - - - - {t("networkFee")} - - - {formatBalance(quote.fees.outbound)} ({quote.fees.totalBps / 100}%) + + {t("insufficientBalanceFix", "Lower the amount to ≤{{have}} or top up the source wallet before swapping.", { have: fmt(b.current) })} - {hasToPrice && ( - - {fmtCompact(parseFloat(quote.fees.outbound) * toPriceUsd)} + + + ) + })()} + + {/* ERC-20 approval status — only render when source is a token. + Distinguishes "approval will be added (2 device prompts)" from + "already approved" so the user knows what to expect. */} + {previewBuild?.allowance && (() => { + const a = previewBuild.allowance + const tokenDecimals = normalizeDecimals(fromAsset.decimals) + const fmt = (raw: string) => { + if (tokenDecimals === null) return raw + try { + const n = Number(BigInt(raw)) / Math.pow(10, tokenDecimals) + if (n > 1e15) return '∞ (max)' + return n.toLocaleString(undefined, { maximumFractionDigits: 6 }) + } catch { return raw } + } + if (a.sufficient) { + return ( + + + + + + + Already approved · {fmt(a.current)} {fromAsset.symbol} allowance to router - )} + {a.spender} + + 1 device confirmation needed: swap + + + ) + } + return ( + + + + + + + Approval needed: {fmt(a.required)} {fromAsset.symbol} + + + Current allowance: {fmt(a.current)} {fromAsset.symbol} · spender {a.spender.slice(0, 10)}…{a.spender.slice(-6)} + + + 2 device confirmations needed: 1) approve {fromAsset.symbol}, 2) swap. The approval consumes gas even if you cancel step 2. + + - - {t("slippage")} - - {(quote.slippageBps / 100).toFixed(2)}% - - - - {t("estimatedTime")} - {formatTime(quote.estimatedTime)} - + ) + })()} + + {/* High-slippage warning — flagged at >HIGH_SLIPPAGE_PCT. + Use max(quote, user) so a tight market quote doesn't hide a loose + user tolerance — the user's setting is the one that bounds risk. */} + {shouldWarnHighSlippage(quote.slippageBps, slippageBps) && ( + + + {t("highSlippageWarning", "Slippage tolerance is high ({{pct}}%). You may receive less than expected. Consider lowering tolerance for small spreads.", { pct: (computeEffectiveSlippageBps(quote.slippageBps, slippageBps) / 100).toFixed(1) })} + + + )} - {quote.router && fromAsset.chainFamily === 'evm' && ( - - {t("routerContract")} - - {quote.router} - - - )} - - {t("vault")} - - {quote.inboundAddress} + {/* Dust-fee warning — protocol fees + spread eat too much of the swap. + THORChain has fixed ~$1.20 BTC outbound fee that crushes small swaps: + $2 in → $1.78 out is 11% loss. Tier the warning so users understand: + >DUST_FEE_WARNING_PCT = strongly recommend bigger amount; + >DUST_FEE_SEVERE_PCT = "you're throwing money away". + Computed from displayed in/out USD values, not just quote.fees.totalBps, + so msg.value EVM fees and spread are captured. */} + {(() => { + const dust = computeDustWarning({ + inAmount: parseFloat(sendAmount) || 0, + outAmount: parseFloat(quote.expectedOutput || '0') || 0, + fromPriceUsd, + toPriceUsd, + }) + if (!dust) return null + const { severe, lossPct, inUsd, lostUsd, recommendedMinUsd } = dust + return ( + + + {severe + ? t("dustFeeSevere", "⚠️ FEES EAT {{pct}}% OF THIS SWAP — you'd lose ${{lostUsd}} of your ${{inUsd}} input. THORChain has a fixed ~$1.20 outbound fee on BTC; small swaps are uneconomic. Strongly recommend ${{minUsd}}+ for this pair, or pick a different route.", { pct: lossPct.toFixed(0), lostUsd: lostUsd.toFixed(2), inUsd: inUsd.toFixed(2), minUsd: recommendedMinUsd }) + : t("dustFeeHigh", "Heads up — fees + spread will cost {{pct}}% of this swap (~${{lostUsd}} of ${{inUsd}}). For small amounts the fixed protocol fee dominates. Larger swaps get a better rate.", { pct: lossPct.toFixed(0), lostUsd: lostUsd.toFixed(2), inUsd: inUsd.toFixed(2) })} + ) + })()} - {quote.warning && ( - {quote.warning} - )} - -
- - {/* Security badge */} - - - {t("verifyOnDevice")} - + {/* TRON blind-sign warning OR verify-on-device note */} + {fromAsset.chainFamily === 'tron' ? ( + + + {t("tronBlindSignWarning", "Your KeepKey will display a generic Tron transaction prompt — it cannot decode the THORChain swap. Verify the amounts, vault, and memo above before approving on device.")} + + + ) : ( + + + {t("verifyOnDevice")} + + )} - {/* Error */} {error && ( - - {error} + + + {error} + + )} - {/* Actions */} + {/* Safety note — "Address will be verified on your KeepKey + device." Per handoff design, this primes the user for the + confirm-on-device step before they sign. */} + + + + + + + + {t("addressVerifiedOnDevice", "Address will be verified on your KeepKey device.")} + + + - - + {reviewConfirmLockLabel || + ((previewBuild?.allowance && !previewBuild.allowance.sufficient) + ? t("approveAndSwap", "Approve & Swap") + : t("confirmSwap"))} +
)} - {/* ── INPUT — side-by-side layout ─────────────────────── */} + {/* ── INPUT — side-by-side You pay / You receive ─────────── */} {!loadingAssets && !assetLoadError && (phase === 'input' || phase === 'quoting') && ( - {/* Side-by-side: FROM | flip | TO */} - + {/* Side-by-side: FROM | center pivot | TO. Pivot is absolutely + positioned over the gap so the two columns stay equal-width. + Hover rotates 180° + glows gold to read as "swap direction". */} + + {/* FROM column */} { setFromAsset(a); setQuote(null); setPhase('input'); setError(null) }} - balances={balances} - exclude={toAsset?.asset} + onOpenPicker={() => setPickerSide('from')} disabled={busy} /> @@ -1731,23 +3298,45 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {fromBalance ? `${formatBalance(fromBalance)} ${fromAsset.symbol}` : '\u2014'} {fromBalance && hasFromPrice && ( - {fmtCompact(parseFloat(fromBalance) * fromPriceUsd)} + {fmtCompact(parseFloat(fromBalance) * fromPriceUsd)} )} {hasFromPrice && ( - - - {inputMode === 'crypto' ? fiatSymbol : fromAsset.symbol} - + // Segmented chip — visible toggle, replaces the + // 9px icon-only button which users couldn't find. + + inputMode !== 'crypto' && toggleInputMode()} + title={t("switchToCrypto") || `Use ${fromAsset.symbol}`}> + {fromAsset.symbol} + + inputMode !== 'fiat' && toggleInputMode()} + title={t("switchToFiat") || `Use ${fiatSymbol}`}> + {fiatSymbol} + + )} + {/* MAX is a set-action, not a toggle. The auto-default + useEffect can pre-enable MAX (small-balance case), + and a toggle here would silently flip that off when + the user clicked MAX expecting it to "do something". + To exit MAX mode, the user types — the input's + onChange already calls setIsMax(false). */} @@ -1758,35 +3347,57 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap $ )} { if (isMax) setIsMax(false); inputMode === 'crypto' ? handleCryptoChange(e.target.value) : handleFiatChange(e.target.value) }} placeholder={inputMode === 'fiat' ? '0.00' : t("amountPlaceholder")} bg="rgba(0,0,0,0.4)" border="1px solid" borderColor={exceedsBalance ? "kk.error" : "rgba(255,255,255,0.08)"} borderRadius="lg" color="kk.textPrimary" size="sm" fontFamily="mono" fontSize="sm" fontWeight="700" disabled={busy} px={inputMode === 'fiat' ? "6" : "3"} - _focus={{ borderColor: exceedsBalance ? "kk.error" : "kk.gold", boxShadow: exceedsBalance ? "none" : "0 0 0 1px rgba(255,215,0,0.3)" }} + _focus={{ borderColor: exceedsBalance ? "kk.error" : "kk.gold", boxShadow: exceedsBalance ? "none" : "0 0 0 1px rgba(233,196,106,0.3)" }} />
{!isMax && hasFromPrice && ( {inputMode === 'crypto' && amountUsdPreview !== null ? ( - {fmtCompact(amountUsdPreview)} + + + ≈ {fmtCompact(amountUsdPreview)} + + ) : inputMode === 'fiat' && amount ? ( - {formatBalance(amount)} {fromAsset.symbol} + + + ≈ {formatBalance(amount)} {fromAsset.symbol} + + ) : null} )} {exceedsBalance && ( {t("insufficientBalance")} )} + {isFeeReservedNativeMax && !nativeMaxInsufficient && fromAsset && ( + + {t("maxReservesGas", { defaultValue: "Reserves ~{{reserve}} {{symbol}} for network fees", reserve: nativeMaxFeeReserve(fromAsset).toString(), symbol: fromAsset.symbol })} + + )} + {nativeMaxInsufficient && fromAsset && ( + + {t("maxInsufficientForGas", { defaultValue: "Balance is below the fee reserve (~{{reserve}} {{symbol}}). Top up to swap.", reserve: nativeMaxFeeReserve(fromAsset).toString(), symbol: fromAsset.symbol })} + + )}
)} {fromAsset && fromAddress && ( - + {fromAddress.slice(0, 10)}...{fromAddress.slice(-6)} @@ -1798,14 +3409,14 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap - + @@ -1814,38 +3425,79 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* TO column */} { setToAsset(a); setQuote(null); setPhase('input'); setError(null) }} - balances={balances} - exclude={fromAsset?.asset} + onOpenPicker={() => setPickerSide('to')} disabled={busy} /> {toAsset && quote && ( - + {t("expectedOutput")} - - + + {hasToPrice && ( - - {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} + + ≈ {fmtCompact(parseFloat(quote.expectedOutput) * toPriceUsd)} )} - {isNativeEvmMax && ( - {t("sendMaxGasNote")} + {isFeeReservedNativeMax && ( + {t("sendMaxGasNote")} )} )} + {/* Quoting placeholder — sits in the same slot the price will + occupy. Reads as "your number is computing here", not as + a separate loading screen tacked on below the form. */} + {toAsset && !quote && phase === 'quoting' && ( + + + {t("expectedOutput")} + + + + + + + + {t("findingBestRoute") || "Finding best route…"} + + + {t("gettingQuote")} + + + + + )} + {toAsset && ( @@ -1853,14 +3505,14 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {!useCustomAddress && ( <> - {t("keepKeyAddress")} + {t("keepKeyAddress")} )} {useCustomAddress && ( - {t("customAddressWarning")} + {t("customAddressWarning")} )} - { setUseCustomAddress(!useCustomAddress); if (useCustomAddress) setCustomToAddress("") }}> {useCustomAddress ? t("useKeepKeyAddress") : t("useCustomAddress")} @@ -1872,14 +3524,14 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap placeholder={t("customAddressPlaceholder")} bg="rgba(0,0,0,0.3)" border="1px solid" borderColor={customAddressError ? "rgba(239,68,68,0.6)" : "rgba(251,163,36,0.2)"} borderRadius="lg" color="kk.textPrimary" size="xs" fontFamily="mono" fontSize="10px" px="2" - _focus={{ borderColor: customAddressError ? "#EF4444" : "#FB923C" }} /> + _focus={{ borderColor: customAddressError ? "var(--rose)" : "var(--gold)" }} /> {customAddressError && ( - {customAddressError} + {customAddressError} )} ) : keepKeyToAddress ? ( - + {keepKeyToAddress.slice(0, 10)}...{keepKeyToAddress.slice(-6)} @@ -1894,19 +3546,121 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap - {/* Quote loading */} - {phase === 'quoting' && ( - - {t("gettingQuote")} + {/* Center pivot — flips fromAsset / toAsset, rotates 180° on + hover. Absolutely positioned so the column widths stay + equal. Disabled while a swap is in flight. */} + { (e.currentTarget as HTMLElement).style.transform = 'translate(-50%, -50%) rotate(180deg)' }} + onMouseLeave={(e: any) => { (e.currentTarget as HTMLElement).style.transform = 'translate(-50%, -50%) rotate(0)' }} + disabled={busy || !fromAsset || !toAsset} + cursor={busy || !fromAsset || !toAsset ? "default" : "pointer"} + opacity={busy || !fromAsset || !toAsset ? 0.4 : 1} + onClick={() => { if (!busy && fromAsset && toAsset) handleFlip() }} + aria-label="Swap direction" + zIndex={2} + > + + + + + + + + + + {/* Slippage tolerance — visible whenever a swap target is selected. + Bps math: 50 = 0.5%, 100 = 1%, 300 = 3%. Custom prompts for a value. */} + {fromAsset && toAsset && ( + + + {t("slippage") || "Slippage"} + + + {[50, 100, 300].map(bps => { + const active = slippageBps === bps + return ( + setSlippageBps(bps)}> + {(bps / 100).toFixed(bps < 100 ? 1 : 0)}% + + ) + })} + { + const raw = prompt(`${t("slippage") || "Slippage"} %`, (slippageBps / 100).toString()) + if (raw == null) return + const pct = parseFloat(raw) + if (!Number.isFinite(pct) || pct <= 0 || pct > 50) { + alert("Enter a percentage between 0.1 and 50") + return + } + setSlippageBps(Math.round(pct * 100)) + }}> + {![50, 100, 300].includes(slippageBps) ? `${(slippageBps / 100).toFixed(2)}%` : (t("custom") || "Custom")} + + )} - {/* Review Swap button — only when quote is ready */} + {/* Review Swap button — only when quote is ready. Gradient + matches the handoff CTA style: teal-2 → teal with a soft + teal glow shadow. Bigger touch target than the prior xs button. */} {phase === 'input' && quote && fromAsset && toAsset && !sameAsset && ( - + )} {/* Hint */} @@ -1916,7 +3670,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* Error */} {error && ( - + {error} )} @@ -1926,19 +3680,153 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap {/* ── Footer ──────────────────────────────────────────────── */} {!loadingAssets && phase !== 'submitted' && !busy && phase !== 'review' && ( - - - THORChain - - {quote?.integration && quote.integration !== 'thorchain' - ? `via ${quote.integration}` - : t("poweredBy")} + + {quote ? ( + + ) : null /* don't claim a provider before we have a quote — was hardcoding THORChain even for Maya-only pairs (e.g. ZEC) */} + + + + + Powered by ShapeShift API - + )} + + {/* ── Asset picker (modal-over-modal) ──────────────────────── */} + {/* ── Swap Provider Health Dialog ──────────────────────────── */} + {healthDialogOpen && ( + setHealthDialogOpen(false)}> + e.stopPropagation()} + style={{ animation: 'kkSwapFadeIn 0.15s ease-out' }} + > + {/* Dialog header */} + + Swap Provider Status + + + + + + + {/* Integration rows */} + + {swapHealth?.integrations.map(intg => { + const info = resolveProvider(intg.key) + const dotColor = + intg.status === 'ok' ? '#22c55e' : + intg.status === 'degraded' ? '#f59e0b' : + intg.status === 'offline' ? '#ef4444' : '#6b7280' + const statusLabel = + intg.status === 'ok' ? 'Operational' : + intg.status === 'degraded' ? 'Degraded' : + intg.status === 'offline' ? 'Offline' : 'Unknown' + const statusDesc = + intg.status === 'ok' ? 'All pools available. Quotes and swaps should work normally.' : + intg.status === 'degraded' ? (intg.detail || 'Some trading pairs are unavailable. Swaps on other pairs may still work.') : + intg.status === 'offline' ? 'Pioneer cannot reach this provider. Quotes requiring this route will fail.' : + 'Status could not be determined.' + return ( + + + + + {intg.label} + + {intg.label} + + + + {statusLabel} + + + {statusDesc} + {intg.haltedPools && intg.haltedPools.length > 0 && ( + + + Halted pools ({intg.haltedPools.length}) + + + {intg.haltedPools.map(caip => ( + + {caip} + + ))} + + + )} + + ) + })} + {!swapHealth && ( + + Loading provider status… + + )} + + + {/* Footer */} + + + {swapHealth ? `Updated ${new Date(swapHealth.fetchedAt).toLocaleTimeString()}` : 'Fetching…'} + + via Pioneer + + + + )} + + setPickerSide(null)} + swappable={assets} + balances={balances} + customTokens={customTokens} + excludeCaip={pickerSide === 'from' ? toAsset?.caip : fromAsset?.caip} + side={pickerSide || 'from'} + onSelect={(a) => { + if (pickerSide === 'from') setFromAsset(a) + else if (pickerSide === 'to') setToAsset(a) + setQuote(null) + setPhase('input') + setError(null) + }} + /> ) } diff --git a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx index a5e05c39..785ade0d 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapHistoryDialog.tsx @@ -11,6 +11,7 @@ import { rpcRequest, onRpcMessage } from "../lib/rpc" import { Z } from "../lib/z-index" import { getExplorerTxUrl } from "../../shared/chains" import type { PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryStats, SwapTrackingStatus } from "../../shared/types" +import { ProviderBadge } from "./ProviderBadge" const ExternalLinkIcon = () => ( @@ -39,12 +40,12 @@ function getStage(status: string): 1 | 2 | 3 { function getStatusColor(status: string): string { switch (status) { case 'signing': return '#A78BFA' - case 'pending': return '#FBBF24' + case 'pending': return 'var(--gold)' case 'confirming': return '#3B82F6' case 'output_detected': return '#23DCC8' case 'output_confirming': return '#3B82F6' case 'output_confirmed': - case 'completed': return '#4ADE80' + case 'completed': return 'var(--teal)' case 'failed': return '#EF4444' case 'refunded': return '#FB923C' default: return '#9CA3AF' @@ -90,7 +91,7 @@ function StageIndicator({ stage, status }: { stage: 1 | 2 | 3; status: string }) {[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)' + const dotColor = isDone ? 'var(--teal)' : isActive ? color : 'rgba(255,255,255,0.15)' return ( {s < 3 && ( - + )} ) @@ -198,7 +199,7 @@ function ActiveSwapCard({ swap, onDismiss, onResume }: { swap: PendingSwap; onDi )} {swap.error && ( - {swap.error} + {swap.error} )} @@ -210,23 +211,26 @@ function ActiveSwapCard({ swap, onDismiss, onResume }: { swap: PendingSwap; onDi {(() => { const url = getExplorerTxUrl(swap.fromChainId, swap.txid) return url ? ( - ) : null })()} {swap.outboundTxid && (() => { - const url = getExplorerTxUrl(swap.toChainId, swap.outboundTxid) + // Refunds outbound on the SOURCE chain (Maya returns the inbound + // asset). Use outboundChainId from the classifier when present + // so the explorer link doesn't 404 on a non-existent ZEC tx hash. + const url = getExplorerTxUrl(swap.outboundChainId || swap.toChainId, swap.outboundTxid) return url ? ( - @@ -302,7 +306,10 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume {record.status} - {formatDate(record.createdAt)} + + + {formatDate(record.createdAt)} + {/* Amounts row */} @@ -322,21 +329,27 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume {/* Quote accuracy badge */} {quoteAccuracy && ( - + {quoteAccuracy.positive ? '+' : ''}{quoteAccuracy.pct}% vs quote )} {record.error && ( - {record.error} + {record.error} )} {/* Expanded details */} {expanded && ( - + + Route + + + {record.swapper && record.integration && record.swapper.toLowerCase() !== record.integration.toLowerCase() && ( + + )} @@ -355,16 +368,16 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume Inbound TX {(() => { const url = getExplorerTxUrl(record.fromChainId, record.txid) return url ? ( - @@ -377,16 +390,19 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume Outbound TX {(() => { - const url = getExplorerTxUrl(record.toChainId, record.outboundTxid) + // Refunded swaps deliver outbound on the source chain. + // outboundChainId from the Maya midgard classifier is the + // truth; fall back to toChainId for legacy records. + const url = getExplorerTxUrl(record.outboundChainId || record.toChainId, record.outboundTxid) return url ? ( - @@ -400,16 +416,16 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume Approval TX {(() => { const url = getExplorerTxUrl(record.fromChainId, record.approvalTxid) return url ? ( - @@ -426,7 +442,7 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume mt="2" w="full" bg="rgba(35,220,200,0.12)" - color="#23DCC8" + color="var(--teal)" fontWeight="600" fontSize="11px" _hover={{ bg: "rgba(35,220,200,0.2)" }} @@ -446,6 +462,7 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume inboundAddress: record.inboundAddress, router: record.router, integration: record.integration, + swapper: record.swapper, status: record.status, confirmations: 0, outboundTxid: record.outboundTxid, @@ -453,6 +470,7 @@ function HistoryCard({ record, onResume }: { record: SwapHistoryRecord; onResume updatedAt: record.updatedAt, estimatedTime: record.estimatedTimeSeconds, error: record.error, + relayRequestId: record.relayRequestId, }) }} > @@ -479,10 +497,10 @@ function DetailRow({ label, value }: { label: string; value: string }) { const STATUS_OPTIONS: { id: StatusFilter; label: string; color: string }[] = [ { id: 'all', label: 'All', color: '#9CA3AF' }, - { id: 'completed', label: 'Completed', color: '#4ADE80' }, + { id: 'completed', label: 'Completed', color: 'var(--teal)' }, { id: 'failed', label: 'Failed', color: '#EF4444' }, { id: 'refunded', label: 'Refunded', color: '#FB923C' }, - { id: 'pending', label: 'Pending', color: '#FBBF24' }, + { id: 'pending', label: 'Pending', color: 'var(--gold)' }, ] // ── Main SwapHistoryDialog ────────────────────────────────────────── @@ -639,11 +657,11 @@ export function SwapHistoryDialog({ open, onClose, onResumeSwap }: SwapHistoryDi {t("swapHistory")} {stats && ( - + {stats.completed} {stats.failed > 0 && ( - + {stats.failed} )} @@ -709,7 +727,7 @@ export function SwapHistoryDialog({ open, onClose, onResumeSwap }: SwapHistoryDi borderRadius="md" flexShrink={0} > - + {exportResult.startsWith('Error') ? exportResult : `Saved to ${exportResult}`} diff --git a/projects/keepkey-vault/src/mainview/components/SweepDialog.tsx b/projects/keepkey-vault/src/mainview/components/SweepDialog.tsx index 8a502618..ae61bde1 100644 --- a/projects/keepkey-vault/src/mainview/components/SweepDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SweepDialog.tsx @@ -187,13 +187,13 @@ export function SweepDialog({ onClose, currentMaxAccountHint, refreshAccounts }: {/* Header */} - + - BTC Sweep Scanner + BTC Sweep Scanner @@ -216,7 +216,7 @@ export function SweepDialog({ onClose, currentMaxAccountHint, refreshAccounts }: Currently tracking accounts 0{currentMaxAccountHint > 0 ? `–${currentMaxAccountHint}` : ''}. {r.address}
@@ -298,7 +298,7 @@ export function SweepDialog({ onClose, currentMaxAccountHint, refreshAccounts }: - + )} diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index 27d31de3..f9d131c5 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -1,21 +1,14 @@ 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 { IS_WINDOWS, IS_MAC } from "../lib/platform" import { useWindowDrag } from "../hooks/useWindowDrag" import { rpcRequest } from "../lib/rpc" import kkIcon from "../assets/icon.png" +import { NAV_HEIGHT } from "../layout" export type NavTab = "vault" | "shapeshift" | "apps" -/** Lock icon for passphrase mode */ -const PassphraseLockIcon = () => ( - - - - -) - interface TopNavProps { label?: string connected: boolean @@ -26,8 +19,10 @@ interface TopNavProps { isEmulator?: boolean onSettingsToggle: () => void onMobileToggle?: () => void + onWalletConnectToggle?: () => void settingsOpen?: boolean mobileOpen?: boolean + walletConnectOpen?: boolean activeTab: NavTab onTabChange: (tab: NavTab) => void watchOnly?: boolean @@ -35,32 +30,66 @@ interface TopNavProps { passphraseActive?: boolean } +const NAV_BG = "rgba(11,11,14,0.92)" +const FONT_SANS = "var(--font-sans, 'Space Grotesk', system-ui, sans-serif)" +const FONT_MONO = "var(--font-mono, 'Geist Mono', ui-monospace, monospace)" + +/** Lock icon for passphrase mode */ +const PassphraseLockIcon = () => ( + + + + +) + /** Construction/hard-hat icon for dev firmware */ const ConstructionIcon = () => ( - - - - + + + + ) /** Shield icon for verified/signed firmware */ const ShieldCheckIcon = ({ color }: { color: string }) => ( - - + + ) -/** Grid icon for Apps tab */ -const GridIcon = () => ( - - - - - - -) +/** Logo tile reused by both nav variants. Subtle ambient glow when active + /signing — gold/teal hint without the alarm-bell red of the old border. */ +const LogoTile = ({ glow, onClick, title }: { glow?: 'idle' | 'connected' | 'signing'; onClick?: () => void; title?: string }) => { + const halo = + glow === 'signing' ? '0 0 14px rgba(233,196,106,0.45)' + : glow === 'connected' ? '0 0 12px rgba(139,227,196,0.30)' + : 'none' + return ( + + + + ) +} /** Minimal nav bar for splash / setup phases. */ export function SplashNav() { @@ -71,29 +100,20 @@ export function SplashNav() { top={0} left={0} right={0} - h="50px" - bg="rgba(0,0,0,0.92)" - backdropFilter="blur(12px)" - borderBottom="1px solid" - borderColor="kk.border" + h={NAV_HEIGHT} + bg={NAV_BG} + backdropFilter="blur(20px)" + borderBottom="1px solid var(--line)" align="center" px="4" zIndex={Z.nav} - {...(!IS_WINDOWS ? { className: "electrobun-webkit-app-region-drag" } : {})} + {...(IS_MAC ? { className: "electrobun-webkit-app-region-drag" } : {})} {...(windowDrag ? { onMouseDown: windowDrag.onMouseDown } : {})} onDoubleClick={IS_WINDOWS ? () => rpcRequest("windowMaximize") : undefined} > - - - + + + KeepKey Vault @@ -101,7 +121,26 @@ export function SplashNav() { ) } -export function TopNav({ label, connected, firmwareVersion, firmwareVerified, needsFirmwareUpdate, latestFirmware, isEmulator, onSettingsToggle, onMobileToggle, settingsOpen, mobileOpen, activeTab, onTabChange, watchOnly, onExitToDeviceSelect, passphraseActive }: TopNavProps) { +export function TopNav({ + label, + connected, + firmwareVersion, + firmwareVerified, + needsFirmwareUpdate, + latestFirmware, + isEmulator, + onSettingsToggle, + onMobileToggle, + onWalletConnectToggle, + settingsOpen, + mobileOpen, + walletConnectOpen, + activeTab, + onTabChange, + watchOnly, + onExitToDeviceSelect, + passphraseActive, +}: TopNavProps) { const { t } = useTranslation("nav") const windowDrag = useWindowDrag() @@ -109,185 +148,232 @@ export function TopNav({ label, connected, firmwareVersion, firmwareVerified, ne { id: "apps", label: t("apps"), - icon: , + icon: ( + + + + + + + ), }, { id: "vault", label: t("keepkey"), - icon: , + icon: , }, { id: "shapeshift", label: t("shapeshift"), - icon: , + icon: , }, ] + + const dotColor = connected ? "var(--teal)" : "var(--text-3)" + const logoGlow = passphraseActive ? 'signing' : connected ? 'connected' : 'idle' + return ( rpcRequest("windowMaximize") : undefined} > - {/* Left: device icon + label */} - - + rpcRequest("openUrl", { url: "https://keepkey.com" }).catch(() => {}))} - className="electrobun-webkit-app-region-no-drag" title={onExitToDeviceSelect ? "Back to device select" : "KeepKey"} - > - - - - - {label || "KeepKey"} - - {watchOnly ? ( - - - {t("watchOnly")} + /> + + + + {label || "KeepKey"} - {onExitToDeviceSelect && ( - + {watchOnly && ( + + {t("watchOnly")} + + )} + {watchOnly && onExitToDeviceSelect && ( + Exit )} - - ) : firmwareVersion ? ( - {isEmulator && ( - - + <> + EMU {onExitToDeviceSelect && ( - + × )} - - )} - {firmwareVerified === false ? ( - <> - - - v{firmwareVersion} (dev) - - ) : needsFirmwareUpdate ? ( - <> - - - v{firmwareVersion} - - {latestFirmware && ( - - → v{latestFirmware} - - )} - - ) : ( - <> - - - v{firmwareVersion} + )} + {passphraseActive && ( + + + + {t("passphraseMode")} - + )} - ) : null} - {passphraseActive && ( - - - - {t("passphraseMode")} - - - )} + {/* Sub-line: connection dot + version. Replaces the hot kk.gold border + of the old layout — same information, calmer signal. */} + {!watchOnly && firmwareVersion ? ( + + + {firmwareVerified === false ? ( + <> + + + v{firmwareVersion} · dev + + + ) : needsFirmwareUpdate ? ( + <> + + + v{firmwareVersion}{latestFirmware ? ` → v${latestFirmware}` : ""} + + + ) : ( + <> + + + v{firmwareVersion} · {connected ? "connected" : "offline"} + + + )} + + ) : !watchOnly ? ( + + + + {connected ? "connected" : "offline"} + + + ) : null} + - {/* Center: navigation tabs (icon above label) */} - + {/* Center: pill-style section nav */} + {TAB_DEFS.map((tab) => { const isActive = activeTab === tab.id return ( onTabChange(tab.id)} - minW="48px" > {tab.icon} - {tab.label} + {tab.label} ) })} - {/* Right: mobile + settings gear */} - + {/* Right: walletconnect + mobile + settings */} + + {onWalletConnectToggle && ( + + + + + + + )} {onMobileToggle && ( - + @@ -298,11 +384,11 @@ export function TopNav({ label, connected, firmwareVersion, firmwareVerified, ne onClick={onSettingsToggle} size="sm" variant="ghost" - color={settingsOpen ? "kk.gold" : "kk.textSecondary"} - _hover={{ color: "kk.gold", bg: "rgba(255,255,255,0.06)" }} + color={settingsOpen ? "var(--gold)" : "var(--text-2)"} + _hover={{ color: "var(--text-0)", bg: "var(--ink-2)" }} className="electrobun-webkit-app-region-no-drag" > - + diff --git a/projects/keepkey-vault/src/mainview/components/TutorialCards.tsx b/projects/keepkey-vault/src/mainview/components/TutorialCards.tsx index 7f5f1996..c0bf3346 100644 --- a/projects/keepkey-vault/src/mainview/components/TutorialCards.tsx +++ b/projects/keepkey-vault/src/mainview/components/TutorialCards.tsx @@ -5,6 +5,7 @@ * Post-tutorial: Verify on Device, REST API, Passphrase (after setup) */ import { Box, Text, VStack, HStack, Flex, Button } from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' import { FaLock, FaEyeSlash, FaKey, FaPen, FaShieldAlt, FaKeyboard, FaCheckCircle, FaDesktop, FaPlug, FaCog, FaUserSecret, @@ -29,12 +30,12 @@ const CARD_CSS = ` function PinGrid() { const nums = [7, 4, 1, 8, 2, 6, 3, 9, 5] // scrambled return ( - + {nums.map((n, i) => ( - - {n} + + {n} ))} @@ -65,7 +66,7 @@ function CipherGrid() { {letters.slice(0, 18).map((l, i) => ( - {l} + {l} ))} @@ -86,6 +87,8 @@ function ToggleOff() { /** Two wallet icons — visible and hidden */ function DualWallets() { + const { t } = useTranslation('setup') + return ( @@ -93,7 +96,7 @@ function DualWallets() { border="1px solid rgba(139,92,246,0.3)" display="flex" alignItems="center" justifyContent="center"> A - visible + {t('tutorial.walletLabels.visible')} @@ -101,7 +104,7 @@ function DualWallets() { border="1px dashed rgba(139,92,246,0.3)" display="flex" alignItems="center" justifyContent="center"> ? - hidden + {t('tutorial.walletLabels.hidden')} ) @@ -113,7 +116,7 @@ function DeviceCheck() { - + @@ -123,8 +126,8 @@ function DeviceCheck() { // ── Card definitions ────────────────────────────────────────────────── interface TutorialCard { - title: string - body: string + titleKey: string + bodyKey: string accent: string icon1: React.ReactNode icon2: React.ReactNode @@ -133,26 +136,26 @@ interface TutorialCard { const PRE_CARDS: TutorialCard[] = [ { - title: 'Your PIN is Scrambled', - body: 'Your KeepKey shows a randomized number grid. Match positions on screen to numbers on device. The layout changes every time so screen-watchers can\'t steal your PIN.', - accent: '#C0A860', - icon1: , + titleKey: 'tutorial.cards.pin.title', + bodyKey: 'tutorial.cards.pin.body', + accent: 'var(--gold)', + icon1: , icon2: , - icon3: , + icon3: , }, { - title: 'Your Words = Your Wallet', - body: 'Write your 12/24 words on paper. Store them somewhere safe. Never type them into a computer, website, or phone. Anyone with these words controls your funds.', + titleKey: 'tutorial.cards.words.title', + bodyKey: 'tutorial.cards.words.body', accent: '#FC8181', - icon1: , + icon1: , icon2: , icon3: , }, { - title: 'Scrambled Recovery Entry', - body: 'When recovering, KeepKey scrambles the alphabet on the device screen. You enter words by position — never by typing actual letters. Keyloggers see nothing useful.', + titleKey: 'tutorial.cards.recovery.title', + bodyKey: 'tutorial.cards.recovery.body', accent: '#23DCC8', - icon1: , + icon1: , icon2: , icon3: , }, @@ -160,10 +163,10 @@ const PRE_CARDS: TutorialCard[] = [ const POST_CARDS: TutorialCard[] = [ { - title: 'Trust Your Device Screen', - body: 'Always confirm the address and amount on your KeepKey before approving. Your computer can be compromised — your device screen cannot. Especially for large transactions.', + titleKey: 'tutorial.cards.deviceScreen.title', + bodyKey: 'tutorial.cards.deviceScreen.body', accent: '#48BB78', - icon1: , + icon1: , icon2: , icon3: @@ -173,16 +176,16 @@ const POST_CARDS: TutorialCard[] = [ , }, { - title: 'App Connections Are Off', - body: 'Third-party apps and dApps connect via the REST API. It\'s disabled by default for your protection. Only enable it in Settings when you need it.', + titleKey: 'tutorial.cards.appConnections.title', + bodyKey: 'tutorial.cards.appConnections.body', accent: '#627EEA', icon1: , icon2: , icon3: , }, { - title: 'Hidden Wallets (Advanced)', - body: 'Passphrase creates a separate hidden wallet from the same seed. If enabled, you MUST remember it — a wrong passphrase opens a different empty wallet, not an error.', + titleKey: 'tutorial.cards.hiddenWallets.title', + bodyKey: 'tutorial.cards.hiddenWallets.body', accent: '#8B5CF6', icon1: , icon2: , @@ -200,10 +203,15 @@ interface TutorialPageProps { } export function TutorialPage({ type, cardIndex, onNext, onSkip }: TutorialPageProps) { + const { t } = useTranslation('setup') const cards = type === 'pre' ? PRE_CARDS : POST_CARDS const card = cards[cardIndex] if (!card) return null const isLast = cardIndex === cards.length - 1 + const nextLabel = isLast + ? t(type === 'pre' ? 'tutorial.actions.getStarted' : 'tutorial.actions.startUsing') + : t('footer.next') + const skipLabel = t(type === 'pre' ? 'tutorial.actions.skipIntro' : 'tutorial.actions.skipTips') return ( @@ -236,12 +244,12 @@ export function TutorialPage({ type, cardIndex, onNext, onSkip }: TutorialPagePr {/* Title */} - {card.title} + {t(card.titleKey)} {/* Body */} - {card.body} + {t(card.bodyKey)} @@ -254,7 +262,7 @@ export function TutorialPage({ type, cardIndex, onNext, onSkip }: TutorialPagePr onClick={onNext} > - {isLast ? (type === 'pre' ? 'Get Started' : 'Start Using KeepKey') : 'Next'} + {nextLabel} @@ -262,13 +270,13 @@ export function TutorialPage({ type, cardIndex, onNext, onSkip }: TutorialPagePr _hover={{ color: 'gray.300', bg: 'rgba(255,255,255,0.04)' }} transition="all 0.15s ease" onClick={onSkip} > - {type === 'pre' ? 'Skip intro' : 'Skip tips'} + {skipLabel} {/* Step counter */} - {cardIndex + 1} of {cards.length} + {t('tutorial.stepCounter', { current: cardIndex + 1, total: cards.length })} ) diff --git a/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx b/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx index bcd02f8e..8c2e0cbb 100644 --- a/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx +++ b/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx @@ -33,14 +33,14 @@ export function UpdateBanner({ phase, progress, message, error, onDownload, onAp // Hidden for idle and checking phases if (phase === "idle" || phase === "checking") return null - // Warning/error: render as subtle bottom-right toast + // Warning/error: subtle bottom-right toast if (phase === "warning" || phase === "error") { if (!toastVisible) return null const isError = phase === "error" - const bg = isError ? "rgba(255,23,68,0.12)" : "rgba(251,191,36,0.08)" - const border = isError ? "rgba(255,23,68,0.25)" : "rgba(251,191,36,0.18)" - const accent = isError ? "#FF6B6B" : "#FBBF24" + const accent = isError ? "var(--rose)" : "var(--gold)" + const bg = isError ? "rgba(224,140,123,0.10)" : "rgba(233,196,106,0.08)" + const border = isError ? "rgba(224,140,123,0.25)" : "rgba(233,196,106,0.22)" return ( - - {isError ? ( - <> - - - - ) : ( - <> - - - - )} - - + + {isError ? t("errorWithMessage", { error: error || message || "Unknown error" }) : t("checkFailed", { defaultValue: "Update check failed" })} - + ) } - // Actionable phases (available, downloading, ready, applying): full-width top banner - const bgColor = - phase === "ready" ? "rgba(34,197,94,0.12)" - : "rgba(192,168,96,0.12)" - - const borderColor = - phase === "ready" ? "rgba(34,197,94,0.3)" - : "rgba(192,168,96,0.3)" - - const accentColor = - phase === "ready" ? "#22C55E" - : "kk.gold" + // Actionable phases: full-width top banner + const isReady = phase === "ready" + const accentColor = isReady ? "var(--teal)" : "var(--gold)" + const accentRgb = isReady ? "139,227,196" : "233,196,106" return ( - {/* Icon */} - {phase === "ready" ? ( + {isReady ? ( - - + + ) : ( - + )} - {/* Text */} - + {phase === "available" && t("newVersionAvailable")} {phase === "downloading" && ( progress !== undefined @@ -163,33 +142,57 @@ export function UpdateBanner({ phase, progress, message, error, onDownload, onAp {phase === "ready" && t("readyToInstall")} {phase === "applying" && t("applying")} - {/* Progress bar for downloading */} {phase === "downloading" && progress !== undefined && ( - - + + )} - {/* Actions */} {phase === "available" && ( <> -
+
+ - - {t("initializePrivacy")} - - )} - {status === "not_running" && ( - {t("zcashCliRequired")} - )} - {status === "initializing" && } - - - {/* Section B: Shielded balance */} - {orchardAddress && ( - - - {t("shieldedBalance")} - - {needsScan ? ( - - - {t("needsScanPrompt")} + + +

{t("engineNotRunning")}

+

+ {t("engineNotRunningDesc")} +

+ {startError && ( + + + {t("engineError")}: {startError} - - ) : balance ? ( - - - - {formatZec(balance.confirmed)} - - ZEC - - {balance.pending > 0 && ( - - {t("pendingBalance")}: {formatZec(balance.pending)} ZEC - - )} - {syncedTo && ( - - {t("lastSynced", { height: syncedTo.toLocaleString(fiatLocale) })} - - )} - - ) : ( - {t("initRequired")} )} -
- )} - - {/* Section F: Shield transparent → Orchard */} - {orchardAddress && ( - - - - - {t("shieldToOrchard")} - - - - {t("shieldDescription")} - - - {shielding ? ( - - - - {shieldStep === "building" ? t("shieldBuilding") : - shieldStep === "signing" ? t("shieldSigning") : - shieldStep === "broadcasting" ? t("shieldBroadcasting") : - t("shieldProcessing")} + {starting ? ( + + + + {t("startingEngine")} ) : ( - - - setShieldAmount(e.target.value)} - size="sm" - type="number" - step="0.00000001" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontFamily="mono" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "kk.gold", boxShadow: "none" }} - flex="1" - /> - {transparentBalance != null && transparentBalance > 0 && ( - - )} - + + + {t("engineSetupOneTime")} + )} +
+
+ ) + } - {shieldResult && ( - - {t("shielded")} - - {shieldResult} - - - )} - {shieldError && ( - {shieldError} - )} - - )} + if (status === "checking" || status === "initializing") { + return ( +
+
+

Setting up privacy

+

Deriving the viewing key from your KeepKey. One-time setup, takes a few seconds.

+
+
+ ) + } - {/* Section F2: Deshield Orchard → transparent */} - {orchardAddress && balance && balance.confirmed > 0 && ( - - - - - {t("deshieldToTransparent")} - - - - {t("deshieldDescription")} - - - {deshielding ? ( - - - - {deshieldStep === "building" ? t("shieldBuilding") : - deshieldStep === "signing" ? t("shieldSigning") : - deshieldStep === "broadcasting" ? t("shieldBroadcasting") : - t("shieldProcessing")} - - + const synced = !needsScan && !scanInFlight && syncedTo != null + + return ( +
+ {/* balance card */} +
+
+ + + +
+
+
Shielded balance
+
+ {balance ? formatZec(balance.confirmed) : "—"} + ZEC + {balance && balance.pending > 0 && ( + + {formatZec(balance.pending)} pending + )} +
+ {scanInFlight && scanProgress ? ( +
+
+ Syncing {Math.floor(displayPercent)}% +
) : ( - - setDeshieldRecipient(e.target.value)} - size="sm" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontFamily="mono" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "#F87171", boxShadow: "none" }} - /> - {deshieldRecipientValidation && !deshieldRecipientValidation.valid && deshieldRecipientValidation.error && ( - {t(deshieldRecipientValidation.error)} - )} - - setDeshieldAmount(e.target.value)} - size="sm" - type="number" - step="0.00000001" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontFamily="mono" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "#F87171", boxShadow: "none" }} - flex="1" - /> - {balance && balance.confirmed > 0 && ( - - )} - - - +
+ {syncedTo + ? `Synced to block #${syncedTo.toLocaleString(fiatLocale)}` + : "Not synced yet"} +
)} +
+
+ + {scanInFlight ? "Syncing" : synced ? "Up to date" : "Idle"} + +
+
- {deshieldResult && ( - - {t("deshielded")} - - {deshieldResult} - - - )} - {deshieldError && ( - {deshieldError} - )} - + {/* page nav */} + + + {/* Inline quick actions + recent activity */} +
+
+ + + +
+ +
+
+
Recent activity
+ +
+
+ {loadingTxs ? ( +
Loading…
+ ) : recentTxs.length === 0 ? ( +
No transactions yet
+ ) : recentTxs.map(tx => ( +
+ + {tx.is_spent ? "Sent" : "Received"} + +
+ {tx.memo + ? <>"{tx.memo.slice(0, 60)}{tx.memo.length > 60 ? "…" : ""}" + : <>Block #{tx.block_height.toLocaleString(fiatLocale)}} +
+
+ {tx.is_spent ? "−" : "+"}{formatZec(tx.value)} ZEC +
+
+ ))} +
+
+
+ + {/* ===== SEND ===== */} + {page === "send" && ( +
+
+

Send ZEC privately

+

Your KeepKey signs the transaction. Recipients and amounts stay encrypted on-chain.

+
+ +
+
+
+
+
+ To +
+ setRecipient(e.target.value)} + /> + +
+
+ {recipientValidation && !recipientValidation.valid && recipientValidation.error && ( +
{t(recipientValidation.error)}
+ )} +
+ Amount + setAmount(e.target.value)} + /> + ZEC + +
+
+ Memo + setMemo(e.target.value)} + /> + {new TextEncoder().encode(memo).length}/512 +
+
+ + + + {!sending && ( +
+
+
kk
+ Verify the recipient and amount on your KeepKey +
+ +
+ )} + + {sendResult && } + {sendError && } +
+
+ +
+
+
Summary
+
Amount{amount || "—"} ZEC
+
Network fee~0.00005
+
PrivacyMaximum
+
+
+
Available to send
+

+ {formatZec(spendableMaxZatoshis)} ZEC +

+

After network fee. Notes need {balance?.min_confirmations ?? 10} confirmations.

+
+
+
+
)} - {/* Section C: Orchard address (receive) */} - {orchardAddress && ( - - - {t("orchardAddress")} - - - - - - {orchardAddress} - - - - - + {/* QR scanner overlay */} + {showScanner && ( + setShowScanner(false)} /> )} - {/* Section D: Scan controls */} - {orchardAddress && ( - - - - {t("scanPayments")} - - setScanFromHeight(String(KEEPKEY_RELEASE_BLOCK))} - > - #{KEEPKEY_RELEASE_BLOCK.toLocaleString(fiatLocale)} - - - - {/* Progress bar — visible during scan */} - {scanState === "scanning" && ( - - {/* Bar track */} - - {/* Filled portion */} - - - - {/* Stats row */} - - - {displayPercent.toFixed(1)}% - - {scanProgress ? ( - - - {scanProgress.scannedHeight.toLocaleString(fiatLocale)} / {scanProgress.tipHeight.toLocaleString(fiatLocale)} - - {scanProgress.blocksPerSec > 0 && ( - - {scanProgress.blocksPerSec.toLocaleString(fiatLocale)} blk/s - + {/* ===== SHIELD / UNSHIELD ===== */} + {page === "shield" && ( +
+
+

Shield & Unshield

+

Shielded ZEC hides amounts and recipients. Transparent ZEC is publicly visible like Bitcoin.

+
+ +
+
+
+
Shield → private
+ From your t-addr +
+
+
+ Available to shield + + {transparentBalanceLoading + ? "loading…" + : transparentBalanceZat == null + ? "—" + : `${formatZec(transparentBalanceZat)} ZEC`} + + {transparentBalanceZat != null && !transparentBalanceLoading && ( + + )} +
+ {transparentPendingZat > 0 && ( +
+ +{formatZec(transparentPendingZat)} ZEC pending — UTXOs need 10 confirmations before they can be shielded (reorg safety). Refresh in a few minutes. +
+ )} +
+
+ Amount + setShieldAmount(e.target.value)} + /> + ZEC + +
+
+ + + {!shielding && ( +
+
+
kk
+ Confirm the shield on your KeepKey +
+ +
+ )} + {shieldResult && } + {shieldError && } +
+
+ +
+
+
Unshield → public
+ Visible on chain +
+
+
+ Available shielded + + {balance ? `${formatZec(spendableMaxZatoshis)} ZEC` : "—"} + + {balance && ( + + after fee · {balance.spendable_notes_count ?? 0} spendable notes + + )} +
+
+
+ To + setDeshieldRecipient(e.target.value)} + /> + {myTransparentAddr && deshieldRecipient !== myTransparentAddr && ( + )} - - ETA {formatEta(scanProgress.etaSeconds)} - - - ) : ( - - - Connecting... - +
+ {myTransparentAddr && deshieldRecipient === myTransparentAddr && ( +
Sending to your own t-addr (m/44'/133'/0'/0/0)
+ )} + {deshieldRecipientValidation && !deshieldRecipientValidation.valid && deshieldRecipientValidation.error && ( +
{t(deshieldRecipientValidation.error)}
+ )} +
+ Amount + setDeshieldAmount(e.target.value)} + /> + ZEC + +
+
+ + + {!deshielding && ( +
+
+ ⚠ This will be publicly visible on chain +
+ +
)} - - - )} + {deshieldResult && } + {deshieldError && } +
+
+
+
+ )} - {/* Scan buttons — hidden during scan */} - {scanState !== "scanning" && ( - - - setScanFromHeight(e.target.value.replace(/\D/g, ""))} - size="sm" - type="text" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontFamily="mono" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "kk.gold", boxShadow: "none" }} - flex="1" - /> - - - +
+
+ + + + {/* Show on device — promoted to a big primary action because verifying + the address on the hardware screen is the only way a user can be sure + the address shown here wasn't swapped by a compromised host. */} +
+
+
{ICO_SHIELD}
+
+
Always verify before receiving large amounts
+
+ Compare the full address on your KeepKey screen to the one shown above. + This is the only defense against a compromised computer swapping the address. + For Zcash, also check the address on the sender's screen — once funds arrive, + the on-chain trail is private to you. +
+
+
+ +
+ ⏱ Takes over 60 seconds — deriving a Zcash address on the + KeepKey requires heavy cryptographic computation (Orchard / Halo 2). The + device will appear busy; this is normal. The full address shows on screen + when the computation completes. +
+ {verifyError &&
{verifyError}
} +
+ + )} + + {/* ===== SCAN / SYNC ===== */} + {page === "scan" && ( +
+
+

Sync

+

Vault scans the Zcash chain locally to find your incoming payments. Your viewing key never leaves this machine.

+
+ +
+
+
+ {scanInFlight || scanState === "scanning" ? "↻" : "✓"} +
+
+
+ {scanState === "scanning" ? "Scanning…" + : scanInFlight ? "Catching up…" + : synced ? "Up to date" + : "Not synced yet"} +
+
+ {syncedTo + ? `Block #${syncedTo.toLocaleString(fiatLocale)}` + : "No blocks scanned"} + {transactions.length > 0 && ` · ${transactions.length} payment${transactions.length === 1 ? "" : "s"}`} +
+
+
+ + {(scanState === "scanning" || scanInFlight) && scanProgress && ( +
+
+
+ {scanProgress.scannedHeight.toLocaleString(fiatLocale)} / {scanProgress.tipHeight.toLocaleString(fiatLocale)} + {scanProgress.blocksPerSec > 0 ? `${scanProgress.blocksPerSec} blk/s · ${formatEta(scanProgress.etaSeconds)} left` : "calculating…"} +
+
+ )} + +
+ - - )} + {scanState === "scanning" ? "Scanning…" : "Sync now"} + +
+
{scanResult && ( - - {scanResult} - +
+
{scanResult}
+
)} - - )} - {/* Section E: Send shielded */} - {orchardAddress && ( - - - {t("sendPrivately")} - - - setRecipient(e.target.value)} - size="sm" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontFamily="mono" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "kk.gold", boxShadow: "none" }} - /> - {recipientValidation && !recipientValidation.valid && recipientValidation.error && ( - {t(recipientValidation.error)} - )} - - setAmount(e.target.value)} - size="sm" - type="number" - step="0.00000001" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontFamily="mono" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "kk.gold", boxShadow: "none" }} - flex="1" - /> - setMemo(e.target.value)} - size="sm" - bg="rgba(255,255,255,0.03)" - borderColor="kk.border" - color="white" - fontSize="12px" - _hover={{ borderColor: "kk.textMuted" }} - _focus={{ borderColor: "kk.gold", boxShadow: "none" }} - flex="1" - /> - - - {sendResult && ( - - {t("txSent")} - - {sendResult} - - +
+ + {advancedScanOpen && ( +
+
+
+ From block + setScanFromHeight(e.target.value.replace(/\D/g, ""))} + /> + +
+
+ + +
+

+ KeepKey shielded support shipped at block {KEEPKEY_RELEASE_BLOCK.toLocaleString(fiatLocale)}. Earlier blocks contain no notes for this account. +

+
+
)} - {sendError && ( - {sendError} - )} - - +
+
)} - {/* Section G: Transaction History with Memos */} - {orchardAddress && ( - - - - - - {t("transactionHistory")} - - - - - - + {/* ===== HISTORY ===== */} + {page === "history" && ( +
+
+

History

+

Decrypted locally with your viewing key.

+
- {backfillResult && ( - {backfillResult} - )} +
+
+ {[ + { id: "all", label: "All" }, + { id: "received", label: "Received" }, + { id: "spent", label: "Spent" }, + { id: "memo", label: "With memo" }, + ].map(f => ( + + ))} +
+ +
+ {backfillResult &&
{backfillResult}
} - {loadingTxs ? ( - - ) : transactions.length === 0 ? ( - - {t("noTransactions")} - - ) : ( - - {transactions.map((tx) => ( - - - - - {tx.is_spent ? "-" : "+"}{formatZec(tx.value)} ZEC - - {tx.memo && ( - - )} - - - { - e.stopPropagation() - navigator.clipboard.writeText(String(tx.block_height)) - }} - onDoubleClick={(e) => { - e.stopPropagation() - setScanFromHeight(String(tx.block_height)) - }} - > - #{tx.block_height.toLocaleString(fiatLocale)} - - - {tx.is_spent ? t("spent") : t("received")} - - - - - {/* Memo display */} - {tx.memo && ( - - setExpandedMemo(expandedMemo === tx.id ? null : tx.id)} - _hover={{ color: "kk.gold" }} - > - - - {expandedMemo === tx.id ? t("collapseMemo") : t("expandMemo")} - - - {expandedMemo === tx.id && ( - + + + + + + + + + + + + + {loadingTxs ? ( + + ) : filteredTxs.length === 0 ? ( + + ) : filteredTxs.map(tx => ( + + + + + + + + + ))} + +
BlockTypeMemoAmountStatusTX
Loading…
No matching transactions
#{tx.block_height.toLocaleString(fiatLocale)} + + + {tx.is_spent ? "Sent" : "Received"} + + + + {tx.memo ? {tx.memo} : } + + {tx.is_spent ? "−" : "+"}{formatZec(tx.value)} ZEC + + + {tx.is_spent ? "Sent" : "Received"} + + + {tx.txid ? ( + + ) : ( + )} - - )} - - ))} - - )} - +
+ +
+ )} + + ) +} + +/** Prominent in-form status panel shown while a Zcash tx is in flight. + * + * building — sidecar building PCZT + Halo 2 proof (~5–30s) + * signing — sent to KeepKey, awaiting user approval (USER MUST LOOK AT DEVICE) + * broadcasting — pushing raw_tx to lightwalletd + * + * Replaces the tiny "submit-hint" mid-form text the old UI used. The "signing" + * state is the one users actually need to act on, so it gets the loudest + * treatment: full-card takeover with the device illustration and an explicit + * "Look at your KeepKey" call. */ +function TxFlowStatus({ step, awaitingButton, kind, intent }: { + step: string | null + /** True when the device just emitted a ButtonRequest — user must press it now. + * False when the device is computing silently (proof gen / Orchard sig). */ + awaitingButton: boolean + /** Visual accent — gold for shield, copper for unshield, blue for send */ + kind: "shield" | "unshield" | "send" + /** Plain-English description of what the user is doing — used in the title */ + intent: string +}) { + if (!step || step === "complete") return null + const accent = kind === "unshield" ? "copper" : kind === "send" ? "blue" : "gold" + const steps: Array<{ id: string; label: string }> = [ + { id: "building", label: "Building proof" }, + { id: "signing", label: "Sign on KeepKey" }, + { id: "broadcasting", label: "Broadcasting" }, + ] + const activeIdx = steps.findIndex(s => s.id === step) + return ( +
+
+ {steps.map((s, i) => { + const state = i < activeIdx ? "done" : i === activeIdx ? "active" : "pending" + return ( +
+
+ {state === "done" ? "✓" : state === "active" ? : i + 1} +
+
{s.label}
+
+ ) + })} +
+ + {step === "building" && ( +
+
Building your {intent}…
+

The Zcash sidecar is generating a Halo 2 zero-knowledge proof. This is normal cryptographic work — typically 5–30 seconds.

+
+ )} + + {step === "signing" && awaitingButton && ( +
+
+ + + + + + + + + + + + +
+
Press the button on your KeepKey →
+

Confirm the {intent} on the device screen, then press the round button.

+
+ )} + + {step === "signing" && !awaitingButton && ( +
+
+ + + + + + + + +
+
Signing on device…
+

Your KeepKey is computing the Orchard signature. This may take 60+ seconds — no input needed yet, just wait.

+
+ )} + + {step === "broadcasting" && ( +
+
Broadcasting to the network…
+

Almost done. Just pushing the signed transaction to a Zcash node.

+
+ )} +
+ ) +} + +function ResultBox({ kind, title, txid, message }: { kind: "ok" | "err"; title: string; txid?: string; message?: string }) { + // Blockchair indexes Zcash transactions reliably; mainnet.zcashexplorer.app + // has been hit-or-miss with newly-broadcast txs. + const explorerUrl = txid ? `https://blockchair.com/zcash/transaction/${txid}` : null + const [copied, setCopied] = useState(false) + const openExplorer = useCallback(async () => { + if (!explorerUrl) return + try { + // System WebView blocks target=_blank — route through Bun, which + // shells out to the OS-native opener (open / xdg-open / cmd start). + await rpcRequest("openExternal", { url: explorerUrl }, 5000) + } catch (e) { + console.error("[ResultBox] failed to open explorer:", e) + } + }, [explorerUrl]) + const copyTxid = useCallback(() => { + if (!txid) return + navigator.clipboard.writeText(txid) + setCopied(true) + setTimeout(() => setCopied(false), 1800) + }, [txid]) + return ( +
+
{title}
+ {txid && ( +
+ {txid} +
+ {explorerUrl && ( + + )} + +
+
)} - + {message &&
{message}
} +
) } diff --git a/projects/keepkey-vault/src/mainview/components/device/PairingApproval.tsx b/projects/keepkey-vault/src/mainview/components/device/PairingApproval.tsx index aa6ed7fe..fc8efc7c 100644 --- a/projects/keepkey-vault/src/mainview/components/device/PairingApproval.tsx +++ b/projects/keepkey-vault/src/mainview/components/device/PairingApproval.tsx @@ -64,8 +64,8 @@ function PairingFallbackIcon() { > {/* Chain link icon */} - - + +
@@ -150,7 +150,7 @@ export function PairingApproval({ request, onApprove, onReject }: PairingApprova w="100%" justify="center" > - + {t("pairing.wantsToConnect")} @@ -159,14 +159,14 @@ export function PairingApproval({ request, onApprove, onReject }: PairingApprova + ) : ( + + )} + + ); +} diff --git a/projects/keepkey-vault/src/mainview/components/v3/PillTabs.tsx b/projects/keepkey-vault/src/mainview/components/v3/PillTabs.tsx new file mode 100644 index 00000000..572ada4f --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/PillTabs.tsx @@ -0,0 +1,75 @@ +import type { CSSProperties } from 'react'; +import { Icon } from './Icon'; +import type { IconName } from './types'; + +export interface PillTabItem { + id: T; + label: string; + icon?: IconName; +} + +interface PillTabsProps { + items: ReadonlyArray>; + active: T; + onChange: (id: T) => void; + /** 'nav' = ink-4 active pill (top nav). 'action' = gold active pill (asset-page tabs). */ + variant?: 'nav' | 'action'; + style?: CSSProperties; +} + +export function PillTabs({ + items, + active, + onChange, + variant = 'nav', + style, +}: PillTabsProps) { + const activeBg = variant === 'action' ? 'var(--gold)' : 'var(--ink-4)'; + const activeColor = variant === 'action' ? 'var(--ink-0)' : 'var(--text-0)'; + const padX = variant === 'action' ? 22 : 18; + const padY = variant === 'action' ? 10 : 8; + const fontSize = variant === 'action' ? 14 : 13; + + return ( + + ); +} diff --git a/projects/keepkey-vault/src/mainview/components/v3/RouteMap.tsx b/projects/keepkey-vault/src/mainview/components/v3/RouteMap.tsx new file mode 100644 index 00000000..e67a222c --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/RouteMap.tsx @@ -0,0 +1,164 @@ +/** + * Animated route visualization for the swap Confirm screen. + * + * Shows the from-token, the integration that's actually executing the swap + * (THORChain / Mayachain / Relay / 0x / ChainFlip / ShapeShift / Bebop / + * etc.), and the to-token, connected by a soft gradient curve. A gold dot + * travels along the path on a 2.4s loop to read as "your value moves + * through this routing layer." + * + * The center node is a 128px branded animation (per-swapper GIF) when the + * caller supplies `centerImageUrl`. We fall back to a small gold-ringed + * glyph circle so unknown providers still render something readable. + */ +import type { ReactNode } from "react"; + +interface TokenSlot { + iconUrl?: string; + caip?: string; + color?: string; + glyph?: string; +} + +interface RouteMapProps { + from: TokenSlot; + to: TokenSlot; + /** Integration label (e.g. "THORChain", "Mayachain", "Relay", "0x", + * "ChainFlip", "Bebop"). Falls back to "ROUTE" when unknown. */ + integration?: string; + /** Branded animation for the center node — see swapper-animations.ts. + * When omitted, a small glyph-ring fallback renders in its place. */ + centerImageUrl?: string; + /** Disable the traveling dot animation (useful for static screenshots + * / when the user reduces motion). Defaults to enabled. */ + animate?: boolean; + /** Slot for an optional explanatory caption below the map. */ + caption?: ReactNode; +} + +/* Pick a 1-3 letter glyph from an arbitrary integration name. */ +function integrationGlyph(name?: string): string { + if (!name) return "•"; + const cleaned = name.trim().toUpperCase(); + if (cleaned.length <= 3) return cleaned; + const words = cleaned.split(/[\s\-_/]+/).filter(Boolean); + if (words.length > 1) return words.slice(0, 3).map(w => w[0]).join(""); + return cleaned.slice(0, 3); +} + +export function RouteMap({ from, to, integration, centerImageUrl, animate = true, caption }: RouteMapProps) { + // 600x180 viewBox: from-token at (60, 90), centerpiece at (300, 90) sized + // 128x128, to-token at (540, 90), label at y=174. + const path = "M 60 90 Q 200 30 300 90 T 540 90"; + const glyph = integrationGlyph(integration); + const centerSize = 128; + const cx = 300; + const cy = 90; + const half = centerSize / 2; + + return ( +
+ + + + + + + + + + + + {/* Dashed background line — gives the curve a subtle "track" feel */} + + + {/* Solid color gradient line — the "real" route */} + + + {/* From-token node (left) */} + + {from.iconUrl ? ( + + ) : ( + <> + + {from.glyph || "?"} + + )} + + {/* Animated traveling dot — drawn before the centerpiece so it visually + "enters" the swapper as it passes through the middle of the curve. */} + {animate && ( + + + + )} + + {/* Centerpiece — branded swap animation when available, glyph ring otherwise. + Sized 128px so it reads as the hero of the route. */} + {centerImageUrl ? ( + <> + {/* Soft halo behind the centerpiece — picks up brand accent */} + + + + ) : ( + <> + + + {glyph} + + + )} + + {/* Integration label below the centerpiece */} + {integration && ( + + {integration.toUpperCase()} + + )} + + {/* To-token node (right) */} + + {to.iconUrl ? ( + + ) : ( + <> + + {to.glyph || "?"} + + )} + + + {caption && ( +
+ {caption} +
+ )} +
+ ); +} diff --git a/projects/keepkey-vault/src/mainview/components/v3/SidePanel.tsx b/projects/keepkey-vault/src/mainview/components/v3/SidePanel.tsx new file mode 100644 index 00000000..3e7327c4 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/SidePanel.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react'; +import { Icon } from './Icon'; +import { TokenLogo } from './TokenLogo'; +import type { TokenLike } from './types'; + +interface SidePanelProps { + token: TokenLike; + /** Eyebrow label, e.g. "You pay" / "You receive". */ + label: string; + /** Border accent override — used on the "receive" side to tint with teal. */ + accent?: string; + /** When provided, renders the "Change" picker pill that calls back when clicked. */ + onChange?: () => void; + children?: ReactNode; +} + +/** Side-by-side swap panel primitive. The pivot button is rendered by the + * parent (it overlays the gap absolutely between two SidePanels). */ +export function SidePanel({ token, label, accent, onChange, children }: SidePanelProps) { + return ( +
+
+ + {label} + + {onChange && ( + + )} +
+
+ +
+
+ {token.sym} +
+ {token.network && ( +
+ {token.network} +
+ )} +
+
+
+ {children} +
+ ); +} diff --git a/projects/keepkey-vault/src/mainview/components/v3/SpinningDevice.tsx b/projects/keepkey-vault/src/mainview/components/v3/SpinningDevice.tsx new file mode 100644 index 00000000..5e92985a --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/SpinningDevice.tsx @@ -0,0 +1,585 @@ +/** + * 360° spinning KeepKey, built as a 6-face CSS-3D box (no Three.js, no WebGL). + * Front face is the OLED screen — content is fully customizable via the + * `screen` slot so the same shell can show "Confirm send / swap quote / + * computing / completed / pin entry" without forking the geometry. + * + * Adapted from the standalone design preview (Spline-style export). Sized to + * the real device's 93×38×12mm proportions. Stationary floor shadow under a + * stage rotating around its Y axis on a CSS keyframe. + */ +import type { CSSProperties, ReactNode } from "react"; + +export interface SpinningDeviceProps { + /** Seconds for one full revolution. Default 14s — quick enough to read as + * motion, slow enough to read the OLED text on every face. */ + durationSeconds?: number; + /** Pause the rotation (e.g. on hover). Defaults to spinning. */ + paused?: boolean; + /** OLED face content. Default: a "CONFIRM SEND" demo screen. Override with + * any JSX — the parent already lives inside an OLED-styled container with + * monospace font, pixel grid, gloss reflection, and `color: var(--ink-0, + * #e8e6dc)` (the warm off-white the real OLED renders). */ + screen?: ReactNode; + /** Show the "keepkey" wordmark on the back face. Default true. */ + showWordmark?: boolean; + /** Container style passthrough — width / margin / etc. */ + style?: CSSProperties; +} + +const FONT_OLED = + 'ui-monospace, "SF Mono", Menlo, Consolas, monospace'; + +// Real KeepKey is ~93×38×12 mm. Pixel scale picked so the spinner reads at +// ~380px wide on a 900px-wide hero. Caller can scale via `style.width`. +const L = 380; // long axis +const W = 158; // short axis +const D = 44; // thickness + +// Anodized aluminum back gradient + sparkle + grain +const MATTE_ALU = + "linear-gradient(180deg, #6a6a70 0%, #57575c 35%, #46464a 65%, #38383c 100%)"; +const ALU_SPARKLE = + "radial-gradient(ellipse at 30% 35%, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 55%), radial-gradient(ellipse at 75% 70%, rgba(120,140,170,0.06) 0%, rgba(0,0,0,0) 60%)"; +const ALU_GRAIN = + "repeating-linear-gradient(92deg, rgba(255,255,255,0.022) 0px, rgba(255,255,255,0.022) 1px, rgba(0,0,0,0.03) 1px, rgba(0,0,0,0.03) 2px)"; + +// Glossy black acrylic front shell + diagonal highlight sweep +const GLOSS_BLACK = + "linear-gradient(180deg, #16161a 0%, #0a0a0c 40%, #050507 100%)"; +const GLOSS_HIGHLIGHT = + "linear-gradient(120deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0) 28%, rgba(255,255,255,0) 72%, rgba(255,255,255,0.06) 100%)"; + +// Shell split: thicker glossy half (~55%) + thinner aluminum half on side faces +const SHELL_BLACK = 0.56; + +interface FaceProps { + w: number; + h: number; + transform: string; + children?: ReactNode; + style?: CSSProperties; +} + +function Face({ w, h, transform, children, style }: FaceProps) { + return ( +
+ {children} +
+ ); +} + +function DefaultDemoScreen() { + return ( +
+
+
+ CONFIRM SEND +
+
+ 0.02400 BTC +
+
+ TO bc1q··7v3x··x4kp +
+
+ FEE 1,240 sats +
+
+
+ ▶ +
+
+ ); +} + +export function SpinningDevice({ + durationSeconds = 14, + paused = false, + screen, + showWordmark = true, + style, +}: SpinningDeviceProps) { + return ( +
+ {/* Stationary floor shadow */} +
+ + {/* 3D box stage */} +
+ {/* FRONT — glossy black OLED with caller-provided screen content */} + +
+ {/* OLED pixel grid texture */} +
+ {/* Diagonal gloss reflection sweep */} +
+ {/* Specular highlight blob */} +
+
+ {screen ?? } +
+
+ + + {/* BACK — matte anodized aluminum + etched wordmark */} + +
+
+
+ {showWordmark && ( +
+ keepkey +
+ )} +
+ + + {/* TOP edge — long horizontal sliver, glossy black + matte aluminum + split, with a small button bump near one end. */} + +
+
+
+
+
+ {/* Hairline seam */} +
+ {/* Confirm button bump — sits ~28% from the right edge */} +
+
+ + + {/* BOTTOM edge — same split, USB connector cutout near one end */} + +
+
+
+
+
+
+ {/* USB-C connector cutout — opposite end from the button */} +
+
+ + + {/* LEFT short end — vertical sliver, same shell split */} + +
+
+
+
+
+
+
+ + + {/* RIGHT short end — mirror of left */} + +
+
+
+
+
+
+
+ +
+ + +
+ ); +} diff --git a/projects/keepkey-vault/src/mainview/components/v3/TokenLogo.tsx b/projects/keepkey-vault/src/mainview/components/v3/TokenLogo.tsx new file mode 100644 index 00000000..7e1f6989 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/TokenLogo.tsx @@ -0,0 +1,68 @@ +import { useState, type CSSProperties, type HTMLAttributes } from 'react'; +import type { TokenLike } from './types'; + +interface TokenLogoProps extends HTMLAttributes { + token: TokenLike; + size?: number; + /** Adds the brand-tinted ambient ring (used on featured / orbital tokens). */ + ring?: boolean; +} + +/** Token logo with graceful fallback to a coloured gradient circle + glyph. + * Mirrors the study's TokenLogo: image is loaded into an ink-2 disc; on + * load failure it swaps to a brand-coloured gradient with the token's glyph. */ +export function TokenLogo({ token, size = 40, ring = false, style, ...rest }: TokenLogoProps) { + const [failed, setFailed] = useState(!token.logo); + const ringStyle: CSSProperties = ring + ? { boxShadow: `0 0 0 1px var(--line), 0 6px 18px -8px ${token.color}` } + : {}; + + if (failed) { + return ( +
+ {token.glyph ?? token.sym.charAt(0)} +
+ ); + } + + return ( +
+ {token.sym} setFailed(true)} + style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} + /> +
+ ); +} diff --git a/projects/keepkey-vault/src/mainview/components/v3/index.ts b/projects/keepkey-vault/src/mainview/components/v3/index.ts new file mode 100644 index 00000000..438c0675 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/index.ts @@ -0,0 +1,12 @@ +export { Icon } from './Icon'; +export { TokenLogo } from './TokenLogo'; +export { KeepKeyDevice } from './KeepKeyDevice'; +export { LogoTile } from './LogoTile'; +export { NetworkRow } from './NetworkRow'; +export { PillTabs } from './PillTabs'; +export { SidePanel } from './SidePanel'; +export { RouteMap } from './RouteMap'; +export { SpinningDevice } from './SpinningDevice'; +export type { TokenLike, NetworkLike, DeviceState, IconName } from './types'; +export type { PillTabItem } from './PillTabs'; +export type { SpinningDeviceProps } from './SpinningDevice'; diff --git a/projects/keepkey-vault/src/mainview/components/v3/types.ts b/projects/keepkey-vault/src/mainview/components/v3/types.ts new file mode 100644 index 00000000..7009daf6 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/v3/types.ts @@ -0,0 +1,34 @@ +/* Narrow prop types for v3 primitives. Decoupled from existing app types + on purpose — callers adapt their own data into these shapes when threading + the new components in. */ + +export type DeviceState = 'idle' | 'active' | 'signing' | 'success'; + +export interface TokenLike { + sym: string; + name?: string; + network?: string; + color: string; + glyph?: string; + logo?: string | null; + chain?: string; + addr?: string; +} + +export interface NetworkLike { + name: string; + color: string; + logo?: string | null; + chain?: string; + sym: string; + glyph?: string; + native?: { amount: number } | null; + tokens: Array<{ sym: string; usd: number }>; + usd: number; +} + +export type IconName = + | 'arrowDown' | 'arrowUp' | 'swap' | 'back' | 'close' | 'plus' + | 'copy' | 'eye' | 'eyeOff' | 'refresh' | 'edit' | 'shield' + | 'gear' | 'apps' | 'external' | 'check' | 'clock' | 'bolt' + | 'arrowRight' | 'chevronDown' | 'sparkle' | 'device'; diff --git a/projects/keepkey-vault/src/mainview/components/zcash-v2-styles.ts b/projects/keepkey-vault/src/mainview/components/zcash-v2-styles.ts new file mode 100644 index 00000000..cbbaa40e --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/zcash-v2-styles.ts @@ -0,0 +1,827 @@ +// Zcash UI styles, scoped under `.zcash-v2`. Originally based on the Claude Design +// handoff at design/Zcash Orchard v2.html, but trimmed down for a beginner-friendly, +// utilitarian feel: smaller headings, no editorial italic, fewer power-user knobs. +export const ZCASH_V2_CSS = ` +.zcash-v2 { + --zk-bg: #0b0b0c; + --zk-bg-1: #121214; + --zk-bg-2: #18181b; + --zk-bg-3: #1f1f23; + --zk-line: #26262a; + --zk-line-soft: #1c1c20; + --zk-fg: #f3f1ec; + --zk-fg-dim: #b8b4ac; + --zk-fg-mute: #74706a; + --zk-fg-faint: #4a4742; + --zk-gold: #c9a368; + --zk-gold-hi: #e0bb7e; + --zk-gold-deep: #8c6c3d; + --zk-gold-soft: rgba(201, 163, 104, 0.14); + --zk-gold-line: rgba(201, 163, 104, 0.28); + --zk-green: #6ee787; + --zk-green-soft: rgba(110, 231, 135, 0.12); + --zk-copper: #d97757; + --zk-copper-soft: rgba(217, 119, 87, 0.12); + --zk-font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; + --zk-font-mono: "JetBrains Mono", ui-monospace, Menlo, monospace; + + color: var(--zk-fg); + font-family: var(--zk-font-display); + letter-spacing: -0.005em; + -webkit-font-smoothing: antialiased; + padding: 4px 4px 24px; +} +.zcash-v2 *, .zcash-v2 *::before, .zcash-v2 *::after { box-sizing: border-box; } +.zcash-v2 button { font-family: inherit; color: inherit; background: none; border: none; cursor: pointer; padding: 0; } +.zcash-v2 button:disabled { opacity: 0.45; cursor: not-allowed; } +.zcash-v2 input { font-family: inherit; color: inherit; background: none; border: none; outline: none; } + +/* ---- balance card (replaces editorial balance strip) ---- */ +.zcash-v2 .zk-balance { + display: flex; align-items: center; gap: 16px; + padding: 18px 20px; + border: 1px solid var(--zk-line); + border-radius: 10px; + background: var(--zk-bg-1); + margin-bottom: 16px; +} +.zcash-v2 .bal-glyph { + width: 40px; height: 40px; border-radius: 9px; + background: linear-gradient(180deg, var(--zk-gold), var(--zk-gold-deep)); + display: grid; place-items: center; + color: #1a1408; flex-shrink: 0; +} +.zcash-v2 .zk-balance .main { flex: 1; min-width: 0; } +.zcash-v2 .zk-balance .lbl { + font-family: var(--zk-font-mono); font-size: 10.5px; + letter-spacing: 0.14em; text-transform: uppercase; + color: var(--zk-fg-mute); +} +.zcash-v2 .zk-balance .amount { + margin-top: 4px; + font-family: var(--zk-font-mono); font-weight: 500; + font-size: 26px; letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.zcash-v2 .zk-balance .amount .ticker { color: var(--zk-gold); font-size: 13px; margin-left: 8px; } +.zcash-v2 .zk-balance .amount .pending { color: var(--zk-fg-mute); font-size: 11px; margin-left: 12px; } +.zcash-v2 .zk-balance .sub { + margin-top: 4px; + font-family: var(--zk-font-mono); font-size: 11px; + color: var(--zk-fg-mute); +} +.zcash-v2 .zk-balance .status-pill { + display: inline-flex; align-items: center; gap: 6px; + font-family: var(--zk-font-mono); font-size: 10.5px; + color: var(--zk-fg-dim); + padding: 5px 10px; border: 1px solid var(--zk-line); + border-radius: 999px; background: var(--zk-bg); + flex-shrink: 0; +} +.zcash-v2 .zk-balance .status-pill .led { + width: 6px; height: 6px; border-radius: 50%; + background: var(--zk-green); + box-shadow: 0 0 0 3px var(--zk-green-soft); +} +.zcash-v2 .zk-balance .status-pill .led.amber { + background: var(--zk-gold); + box-shadow: 0 0 0 3px var(--zk-gold-soft); +} +.zcash-v2 .zk-balance .syncing { + margin-top: 8px; + display: flex; align-items: center; gap: 10px; + font-family: var(--zk-font-mono); font-size: 10.5px; + color: var(--zk-fg-mute); +} +.zcash-v2 .zk-balance .syncing .pb { + flex: 1; height: 4px; background: var(--zk-bg-3); + border-radius: 999px; overflow: hidden; +} +.zcash-v2 .zk-balance .syncing .pb-fill { + height: 100%; background: linear-gradient(90deg, var(--zk-gold-deep), var(--zk-gold)); + transition: width 0.2s ease-out; +} + +/* ---- page nav ---- */ +.zcash-v2 .page-nav { + display: flex; gap: 2px; + border-bottom: 1px solid var(--zk-line); + margin-bottom: 20px; + padding: 0; + overflow-x: auto; + scrollbar-width: none; +} +.zcash-v2 .page-nav::-webkit-scrollbar { display: none; } +.zcash-v2 .page-nav button { + padding: 10px 14px; + font-family: var(--zk-font-display); font-size: 13px; + font-weight: 500; + color: var(--zk-fg-mute); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + flex-shrink: 0; + transition: color 120ms, background 120ms; + white-space: nowrap; + display: inline-flex; align-items: center; gap: 8px; +} +.zcash-v2 .page-nav button .ico { + display: inline-flex; align-items: center; + opacity: 0.55; + transition: opacity 120ms, transform 120ms; +} +.zcash-v2 .page-nav button:hover { color: var(--zk-fg-dim); } +.zcash-v2 .page-nav button:hover .ico { opacity: 0.95; } +.zcash-v2 .page-nav button[data-active="1"] { color: var(--zk-fg); border-bottom-color: currentColor; } +.zcash-v2 .page-nav button[data-active="1"] .ico { opacity: 1; } +/* Active tab adopts the icon's accent color for the underline + label hint. */ +.zcash-v2 .page-nav button[data-active="1"]:nth-child(1) { color: #c9a368; border-bottom-color: #c9a368; } +.zcash-v2 .page-nav button[data-active="1"]:nth-child(2) { color: #d97757; border-bottom-color: #d97757; } +.zcash-v2 .page-nav button[data-active="1"]:nth-child(3) { color: #6ee787; border-bottom-color: #6ee787; } +.zcash-v2 .page-nav button[data-active="1"]:nth-child(4) { color: #7aa6f0; border-bottom-color: #7aa6f0; } +.zcash-v2 .page-nav button[data-active="1"]:nth-child(5) { color: #b794f4; border-bottom-color: #b794f4; } +.zcash-v2 .page-nav button[data-active="1"]:nth-child(6) { color: #56d4d4; border-bottom-color: #56d4d4; } + +/* ---- verify-on-device card (Receive page) ---- */ +.zcash-v2 .verify-card { + margin-top: 16px; + padding: 20px 22px; + border: 1px solid var(--zk-gold-line); + background: linear-gradient(180deg, rgba(201,163,104,0.06), transparent); + border-radius: 10px; +} +.zcash-v2 .verify-head { display: flex; gap: 14px; margin-bottom: 16px; } +.zcash-v2 .verify-ico { + width: 36px; height: 36px; flex-shrink: 0; + border-radius: 9px; + background: linear-gradient(180deg, var(--zk-gold), var(--zk-gold-deep)); + color: #1a1408; + display: grid; place-items: center; +} +.zcash-v2 .verify-ico svg { width: 18px; height: 18px; } +.zcash-v2 .verify-title { + font-family: var(--zk-font-display); font-size: 14px; font-weight: 600; + color: var(--zk-fg); margin-bottom: 4px; +} +.zcash-v2 .verify-sub { + font-family: var(--zk-font-display); font-size: 12.5px; + color: var(--zk-fg-dim); line-height: 1.55; +} +.zcash-v2 .verify-btn { font-size: 15px; padding: 16px 24px; } +.zcash-v2 .verify-btn-ico { display: inline-flex; align-items: center; } +.zcash-v2 .verify-btn-ico svg { width: 16px; height: 16px; } +.zcash-v2 .verify-card .verify-note { + margin-top: 10px; + padding: 10px 14px; + border-radius: 6px; + background: var(--zk-bg); + border: 1px solid var(--zk-line); + font-family: var(--zk-font-display); font-size: 12px; + color: var(--zk-fg-mute); line-height: 1.5; + display: block; text-transform: none; letter-spacing: 0; +} +.zcash-v2 .verify-card .verify-note strong { color: var(--zk-gold); } + +/* ---- card primitives ---- */ +.zcash-v2 .card { + border: 1px solid var(--zk-line); + border-radius: 10px; + background: var(--zk-bg-1); + overflow: hidden; + position: relative; + min-width: 0; +} +.zcash-v2 .card-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--zk-line-soft); + gap: 12px; +} +.zcash-v2 .card-head .title { + display: flex; align-items: center; gap: 10px; + font-family: var(--zk-font-display); font-size: 13px; + font-weight: 500; + color: var(--zk-fg); +} +.zcash-v2 .card-head .meta { + font-family: var(--zk-font-mono); font-size: 10.5px; + color: var(--zk-fg-mute); letter-spacing: 0.04em; +} +.zcash-v2 .card-body { padding: 18px; } + +/* ---- page header ---- */ +.zcash-v2 .page-head { + margin-bottom: 16px; +} +.zcash-v2 .page-head h2 { + margin: 0; + font-family: var(--zk-font-display); + font-size: 18px; font-weight: 600; + letter-spacing: -0.015em; +} +.zcash-v2 .page-head p { + margin: 6px 0 0; + color: var(--zk-fg-mute); font-size: 12.5px; + max-width: 60ch; line-height: 1.5; +} + +/* ---- form fields ---- */ +.zcash-v2 .field { + border: 1px solid var(--zk-line); + border-radius: 6px; + background: var(--zk-bg); + padding: 10px 12px; + display: flex; align-items: center; gap: 10px; + transition: border-color 120ms; +} +.zcash-v2 .field:focus-within { border-color: var(--zk-gold-line); } +.zcash-v2 .field .lbl { + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-mute); flex-shrink: 0; width: 80px; + font-weight: 500; +} +.zcash-v2 .field input { flex: 1; min-width: 0; font-family: var(--zk-font-mono); font-size: 13px; color: var(--zk-fg); } +.zcash-v2 .field input::placeholder { color: var(--zk-fg-faint); } +.zcash-v2 .field .suffix { font-family: var(--zk-font-mono); font-size: 11px; color: var(--zk-fg-mute); flex-shrink: 0; } +.zcash-v2 .field .max { + font-family: var(--zk-font-display); font-size: 11px; + color: var(--zk-gold); letter-spacing: 0.04em; text-transform: uppercase; + padding: 3px 8px; + border: 1px solid var(--zk-gold-line); border-radius: 3px; + background: var(--zk-gold-soft); + flex-shrink: 0; font-weight: 600; +} +.zcash-v2 .field .max:hover:not(:disabled) { background: rgba(201,163,104,0.22); } +.zcash-v2 .field-err { + font-family: var(--zk-font-display); font-size: 12px; + color: var(--zk-copper); padding: 0 2px; +} +.zcash-v2 .field-hint { + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-mute); padding: 0 2px; + display: inline-flex; align-items: center; gap: 6px; +} +.zcash-v2 .field-hint::before { + content: ""; width: 4px; height: 4px; border-radius: 50%; + background: var(--zk-green); +} +/* QR scan button inside field */ +.zcash-v2 .zk-qr-field { + display: flex; align-items: center; gap: 8px; flex: 1 1 60%; +} +.zcash-v2 .qr-scan-btn { + flex-shrink: 0; + width: 28px; height: 28px; + border-radius: 6px; + display: flex; align-items: center; justify-content: center; + color: var(--zk-fg-dim); +} +.zcash-v2 .qr-scan-btn:hover { background: var(--zk-bg-3); color: var(--zk-gold); } +.zcash-v2 .balance-row { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 10px 12px; + margin-bottom: 12px; + border: 1px solid var(--zk-line); + border-radius: 6px; + background: var(--zk-bg); + font-family: var(--zk-font-display); font-size: 12px; + color: var(--zk-fg-mute); +} +.zcash-v2 .balance-row strong { + font-family: var(--zk-font-mono); font-weight: 500; + font-size: 13.5px; color: var(--zk-gold); + font-variant-numeric: tabular-nums; +} +.zcash-v2 .balance-row .balance-hint { + color: var(--zk-fg-faint); font-size: 11px; + margin-left: auto; +} +.zcash-v2 .balance-row .ghost-btn { padding: 3px 8px; font-size: 12px; margin-left: auto; } +.zcash-v2 .pending-note { + margin-top: -6px; margin-bottom: 12px; + padding: 8px 12px; + border-left: 3px solid var(--zk-gold); + background: rgba(201,163,104,0.06); + border-radius: 0 6px 6px 0; + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-dim); line-height: 1.5; +} +.zcash-v2 .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.zcash-v2 .field-grid { display: grid; gap: 12px; } + +/* ---- buttons ---- */ +.zcash-v2 .submit { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 12px 22px; + background: var(--zk-gold); color: #1a1408; + font-family: var(--zk-font-display); font-weight: 600; + font-size: 13.5px; border-radius: 6px; + transition: transform 120ms, background 120ms; +} +.zcash-v2 .submit:hover:not(:disabled) { background: var(--zk-gold-hi); } +.zcash-v2 .submit.alt { + background: transparent; color: var(--zk-fg); + box-shadow: 0 0 0 1px var(--zk-line) inset; +} +.zcash-v2 .submit.alt:hover:not(:disabled) { background: var(--zk-bg-2); box-shadow: 0 0 0 1px var(--zk-fg-faint) inset; } +.zcash-v2 .submit.warn { + background: var(--zk-copper); color: #2a1308; +} +.zcash-v2 .submit.warn:hover:not(:disabled) { background: #ea8a6e; } +.zcash-v2 .submit.lg { padding: 14px 24px; font-size: 14px; width: 100%; } + +.zcash-v2 .ghost-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 11px; + border: 1px solid var(--zk-line); border-radius: 4px; + font-family: var(--zk-font-display); font-size: 12px; + color: var(--zk-fg-dim); +} +.zcash-v2 .ghost-btn:hover:not(:disabled) { border-color: var(--zk-fg-faint); color: var(--zk-fg); background: var(--zk-bg-2); } + +.zcash-v2 .submit-row { + margin-top: 18px; + display: flex; gap: 10px; align-items: center; justify-content: flex-end; +} +.zcash-v2 .submit-hint { + flex: 1; + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-mute); + display: flex; align-items: center; gap: 8px; +} +.zcash-v2 .submit-hint .kk-glyph { + width: 18px; height: 18px; + border-radius: 4px; + background: var(--zk-fg); color: var(--zk-bg); + display: grid; place-items: center; + font-weight: 600; font-size: 9px; + letter-spacing: -0.04em; flex-shrink: 0; +} + +/* ---- tx flow status (in-form takeover during signing) ---- */ +.zcash-v2 .tx-flow { + margin-top: 12px; + border: 1px solid var(--zk-line); + border-radius: 10px; + background: var(--zk-bg); + overflow: hidden; +} +.zcash-v2 .tx-flow-gold { border-color: var(--zk-gold-line); background: linear-gradient(180deg, rgba(201,163,104,0.06), transparent); } +.zcash-v2 .tx-flow-copper { border-color: rgba(217,119,87,0.3); background: linear-gradient(180deg, rgba(217,119,87,0.05), transparent); } +.zcash-v2 .tx-flow-blue { border-color: rgba(122,166,240,0.35); background: linear-gradient(180deg, rgba(122,166,240,0.05), transparent); } + +.zcash-v2 .tx-flow-stepper { + display: grid; grid-template-columns: repeat(3, 1fr); + padding: 14px 18px; + gap: 10px; + border-bottom: 1px solid var(--zk-line-soft); + position: relative; +} +.zcash-v2 .tx-flow-step { + display: flex; align-items: center; gap: 10px; + font-family: var(--zk-font-display); font-size: 12px; + color: var(--zk-fg-faint); + min-width: 0; +} +.zcash-v2 .tx-flow-step.done { color: var(--zk-fg-mute); } +.zcash-v2 .tx-flow-step.active { color: var(--zk-fg); font-weight: 600; } +.zcash-v2 .tx-flow-dot { + width: 22px; height: 22px; flex-shrink: 0; + border-radius: 50%; + border: 1.5px solid currentColor; + display: grid; place-items: center; + font-family: var(--zk-font-mono); font-size: 11px; font-weight: 600; +} +.zcash-v2 .tx-flow-step.done .tx-flow-dot { + color: var(--zk-green); border-color: var(--zk-green); + background: var(--zk-green-soft); +} +.zcash-v2 .tx-flow-step.active .tx-flow-dot { + border-style: dashed; +} +.zcash-v2 .tx-flow-gold .tx-flow-step.active .tx-flow-dot { color: var(--zk-gold); background: var(--zk-gold-soft); } +.zcash-v2 .tx-flow-copper .tx-flow-step.active .tx-flow-dot { color: var(--zk-copper); background: var(--zk-copper-soft); } +.zcash-v2 .tx-flow-blue .tx-flow-step.active .tx-flow-dot { color: #7aa6f0; background: rgba(122,166,240,0.12); } +.zcash-v2 .tx-flow-spin { + width: 10px; height: 10px; + border-radius: 50%; + border: 1.5px solid currentColor; + border-top-color: transparent; + animation: zk-spin 0.9s linear infinite; +} +@keyframes zk-spin { to { transform: rotate(360deg); } } +.zcash-v2 .tx-flow-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.zcash-v2 .tx-flow-body { + padding: 18px 22px; + display: flex; flex-direction: column; align-items: center; + text-align: center; +} +.zcash-v2 .tx-flow-headline { + font-family: var(--zk-font-display); font-size: 16px; font-weight: 600; + color: var(--zk-fg); margin-bottom: 6px; +} +.zcash-v2 .tx-flow-body p { + margin: 0; font-size: 12.5px; color: var(--zk-fg-dim); + line-height: 1.55; max-width: 56ch; +} +.zcash-v2 .tx-flow-body p strong { color: var(--zk-fg); } + +/* "Look at your KeepKey" — the loudest variant */ +.zcash-v2 .tx-flow-signing { padding: 26px 22px 24px; } +.zcash-v2 .tx-flow-signing .tx-flow-headline { + font-size: 22px; margin-bottom: 8px; + color: var(--zk-gold); + letter-spacing: -0.015em; +} +.zcash-v2 .tx-flow-copper .tx-flow-signing .tx-flow-headline { color: var(--zk-copper); } +.zcash-v2 .tx-flow-blue .tx-flow-signing .tx-flow-headline { color: #7aa6f0; } +.zcash-v2 .tx-flow-device { + color: var(--zk-gold); + margin-bottom: 14px; + animation: zk-pulse 2.4s ease-in-out infinite; +} +.zcash-v2 .tx-flow-device.tx-flow-device-active { + animation: zk-pulse-strong 0.9s ease-in-out infinite; +} +.zcash-v2 .tx-flow-copper .tx-flow-device { color: var(--zk-copper); } +.zcash-v2 .tx-flow-blue .tx-flow-device { color: #7aa6f0; } +@keyframes zk-pulse { + 0%, 100% { opacity: 0.55; transform: translateY(0); } + 50% { opacity: 0.9; transform: translateY(-1px); } +} +@keyframes zk-pulse-strong { + 0%, 100% { opacity: 0.85; transform: translateY(0) scale(1); } + 50% { opacity: 1; transform: translateY(-3px) scale(1.04); } +} +/* "Press the button" variant — slightly brighter background to signal urgency */ +.zcash-v2 .tx-flow-press { + background: rgba(201,163,104,0.08); +} +.zcash-v2 .tx-flow-copper .tx-flow-press { background: rgba(217,119,87,0.07); } +.zcash-v2 .tx-flow-blue .tx-flow-press { background: rgba(122,166,240,0.07); } + +/* ---- result box ---- */ +.zcash-v2 .result-box { + margin-top: 14px; + padding: 12px 14px; + border-radius: 7px; + border: 1px solid var(--zk-line); +} +.zcash-v2 .result-box.ok { + background: var(--zk-green-soft); + border-color: rgba(110,231,135,0.3); +} +.zcash-v2 .result-box.err { + background: var(--zk-copper-soft); + border-color: rgba(217,119,87,0.3); +} +.zcash-v2 .result-title { + font-family: var(--zk-font-display); font-size: 12.5px; + font-weight: 600; + margin-bottom: 6px; +} +.zcash-v2 .result-box.ok .result-title { color: var(--zk-green); } +.zcash-v2 .result-box.err .result-title { color: var(--zk-copper); } +.zcash-v2 .result-txid { + display: flex; flex-direction: column; gap: 8px; + font-family: var(--zk-font-mono); font-size: 11.5px; + color: var(--zk-fg-dim); line-height: 1.5; +} +.zcash-v2 .result-txid .txid-hash { + display: block; + word-break: break-all; + padding: 8px 10px; + border-radius: 4px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--zk-line-soft); + user-select: all; +} +.zcash-v2 .result-txid .txid-actions { + display: flex; gap: 6px; flex-wrap: wrap; +} +.zcash-v2 .result-txid button { + display: inline-flex; align-items: center; gap: 4px; + color: var(--zk-gold); + font-family: var(--zk-font-display); font-size: 12px; font-weight: 500; + padding: 5px 10px; border-radius: 4px; + border: 1px solid var(--zk-gold-line); background: var(--zk-gold-soft); + cursor: pointer; +} +.zcash-v2 .result-txid button:hover { background: rgba(201,163,104,0.22); } +.zcash-v2 .result-txid button.txid-copy { + color: var(--zk-fg-dim); + border-color: var(--zk-line); background: var(--zk-bg); +} +.zcash-v2 .result-txid button.txid-copy:hover { + color: var(--zk-fg); background: var(--zk-bg-2); border-color: var(--zk-fg-faint); +} +.zcash-v2 .result-msg { color: var(--zk-fg-dim); font-size: 12.5px; line-height: 1.5; } + +/* ---- OVERVIEW ---- */ +.zcash-v2 .quick-actions { + display: grid; grid-template-columns: repeat(3, 1fr); + gap: 10px; margin-bottom: 16px; +} +.zcash-v2 .quick-action { + border: 1px solid var(--zk-line); + border-radius: 8px; + background: var(--zk-bg-1); + padding: 16px 18px; + text-align: left; + transition: border-color 120ms, background 120ms; +} +.zcash-v2 .quick-action:hover { + border-color: var(--zk-gold-line); + background: rgba(201, 163, 104, 0.04); +} +.zcash-v2 .quick-action .qa-title { + font-family: var(--zk-font-display); font-size: 14px; + font-weight: 600; color: var(--zk-fg); +} +.zcash-v2 .quick-action .qa-sub { + margin-top: 4px; + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-mute); line-height: 1.4; +} + +.zcash-v2 .recent-list { display: flex; flex-direction: column; } +.zcash-v2 .recent-empty { padding: 24px; text-align: center; color: var(--zk-fg-mute); font-size: 12.5px; } +.zcash-v2 .recent-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 14px; + padding: 12px 18px; + align-items: center; + border-top: 1px solid var(--zk-line-soft); + font-size: 13px; +} +.zcash-v2 .recent-row:first-of-type { border-top: none; } +.zcash-v2 .recent-row .pill { + font-family: var(--zk-font-mono); font-size: 9.5px; + letter-spacing: 0.1em; text-transform: uppercase; + padding: 2px 6px; border-radius: 2px; +} +.zcash-v2 .pill-orchard { background: var(--zk-gold-soft); color: var(--zk-gold); border: 1px solid var(--zk-gold-line); } +.zcash-v2 .pill-trans { background: rgba(255,255,255,0.04); color: var(--zk-fg-dim); border: 1px solid var(--zk-line); } +.zcash-v2 .recent-row .label { color: var(--zk-fg-dim); font-size: 12.5px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zcash-v2 .recent-row .label .memo { color: var(--zk-fg-mute); font-size: 11px; font-family: var(--zk-font-mono); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; } +.zcash-v2 .recent-row .v { font-family: var(--zk-font-mono); font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; flex-shrink: 0; } +.zcash-v2 .amount-pos { color: var(--zk-green); } +.zcash-v2 .amount-neg { color: var(--zk-fg); } + +/* ---- form pages ---- */ +.zcash-v2 .form-page { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} +.zcash-v2 .form-page.with-aside { + /* minmax(0,...) lets each column actually shrink to its share. Without it, + the implicit auto minimum keeps a column at min-content width and + pushes input suffixes / Max button / char counters out of view. */ + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + align-items: start; +} +.zcash-v2 .form-aside { display: grid; gap: 12px; align-content: start; min-width: 0; } +.zcash-v2 .aside-card { + border: 1px solid var(--zk-line); + border-radius: 10px; + background: var(--zk-bg-1); + padding: 14px 16px; +} +.zcash-v2 .aside-card h5 { + margin: 0 0 8px; + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-mute); font-weight: 600; +} +.zcash-v2 .aside-card .kv { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; align-items: baseline; + padding: 6px 0; + border-top: 1px dashed var(--zk-line-soft); + font-size: 12px; color: var(--zk-fg-dim); +} +.zcash-v2 .aside-card .kv:first-of-type { border-top: none; padding-top: 0; } +.zcash-v2 .aside-card .kv .v { font-family: var(--zk-font-mono); color: var(--zk-fg); font-variant-numeric: tabular-nums; text-align: right; word-break: break-word; } +.zcash-v2 .aside-card .kv .v.gold { color: var(--zk-gold); } +.zcash-v2 .aside-card .kv .v.cp { color: var(--zk-copper); } +.zcash-v2 .aside-card .kv .v.gn { color: var(--zk-green); } +.zcash-v2 .aside-card p { + margin: 0; + font-size: 12px; color: var(--zk-fg-dim); line-height: 1.55; +} + +/* ---- RECEIVE ---- */ +.zcash-v2 .receive-grid { + display: grid; grid-template-columns: 200px 1fr; + gap: 22px; align-items: start; +} +.zcash-v2 .qr-wrap { + aspect-ratio: 1/1; + background: var(--zk-fg); + border-radius: 8px; + padding: 12px; +} +.zcash-v2 .qr-wrap > div { width: 100%; height: 100%; } +.zcash-v2 .qr-wrap svg { display: block; width: 100%; height: 100%; } +.zcash-v2 .addr-eyebrow { + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-fg-mute); font-weight: 500; margin-bottom: 8px; +} +.zcash-v2 .addr-box { + font-family: var(--zk-font-mono); font-size: 12.5px; + color: var(--zk-fg-dim); + background: var(--zk-bg); + border: 1px solid var(--zk-line); border-radius: 6px; + padding: 12px 14px; + word-break: break-all; line-height: 1.55; + margin-bottom: 10px; +} +.zcash-v2 .addr-actions { display: flex; gap: 6px; flex-wrap: wrap; } + +/* ---- SCAN ---- */ +.zcash-v2 .sync-status { + display: flex; align-items: center; gap: 14px; + padding: 18px 20px; +} +.zcash-v2 .sync-status .ico { + width: 36px; height: 36px; border-radius: 50%; + background: var(--zk-green-soft); + color: var(--zk-green); + display: grid; place-items: center; + font-size: 18px; flex-shrink: 0; +} +.zcash-v2 .sync-status .ico.syncing { + background: var(--zk-gold-soft); color: var(--zk-gold); +} +.zcash-v2 .sync-status .text { flex: 1; min-width: 0; } +.zcash-v2 .sync-status .text .title { + font-family: var(--zk-font-display); font-size: 14px; + font-weight: 600; color: var(--zk-fg); +} +.zcash-v2 .sync-status .text .sub { + margin-top: 3px; + font-family: var(--zk-font-mono); font-size: 11.5px; + color: var(--zk-fg-mute); +} +.zcash-v2 .sync-progress { + padding: 0 20px 18px; +} +.zcash-v2 .sync-progress .pb { + height: 5px; border-radius: 999px; + background: var(--zk-bg-3); overflow: hidden; +} +.zcash-v2 .sync-progress .pb-fill { + height: 100%; + background: linear-gradient(90deg, var(--zk-gold-deep), var(--zk-gold)); + transition: width 0.2s ease-out; +} +.zcash-v2 .sync-progress .meta { + margin-top: 8px; + display: flex; justify-content: space-between; + font-family: var(--zk-font-mono); font-size: 11px; + color: var(--zk-fg-mute); +} +.zcash-v2 .scan-cta { + display: flex; gap: 10px; padding: 14px 20px; + border-top: 1px solid var(--zk-line-soft); + background: var(--zk-bg); +} + +/* ---- advanced disclosure ---- */ +.zcash-v2 .advanced { + margin-top: 14px; + border: 1px solid var(--zk-line); + border-radius: 8px; + overflow: hidden; +} +.zcash-v2 .advanced-toggle { + display: flex; align-items: center; justify-content: space-between; + width: 100%; + padding: 12px 16px; + font-family: var(--zk-font-display); font-size: 12.5px; + font-weight: 500; color: var(--zk-fg-dim); +} +.zcash-v2 .advanced-toggle:hover { color: var(--zk-fg); background: var(--zk-bg-2); } +.zcash-v2 .advanced-toggle .chev { font-family: var(--zk-font-mono); color: var(--zk-fg-mute); } +.zcash-v2 .advanced-body { + padding: 16px; + border-top: 1px solid var(--zk-line-soft); + background: var(--zk-bg); +} + +/* ---- HISTORY ---- */ +.zcash-v2 .history-controls { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 12px; gap: 10px; flex-wrap: wrap; +} +.zcash-v2 .filter-chip { + font-family: var(--zk-font-display); font-size: 12px; + color: var(--zk-fg-mute); font-weight: 500; + padding: 6px 12px; + border: 1px solid var(--zk-line); border-radius: 999px; +} +.zcash-v2 .filter-chip[data-active="1"] { + color: var(--zk-bg); background: var(--zk-fg); border-color: var(--zk-fg); +} +.zcash-v2 .history-card table { + width: 100%; border-collapse: collapse; + font-family: var(--zk-font-display); font-size: 12.5px; +} +.zcash-v2 .history-card th { + text-align: left; + padding: 10px 18px; + font-size: 11px; + color: var(--zk-fg-mute); font-weight: 500; + border-bottom: 1px solid var(--zk-line); + background: var(--zk-bg); +} +.zcash-v2 .history-card th.num, .zcash-v2 .history-card td.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--zk-font-mono); } +.zcash-v2 .history-card td { + padding: 12px 18px; + border-bottom: 1px solid var(--zk-line-soft); + color: var(--zk-fg-dim); + vertical-align: middle; +} +.zcash-v2 .history-card td.block { font-family: var(--zk-font-mono); font-size: 11.5px; color: var(--zk-fg-mute); } +.zcash-v2 .history-card tr:last-child td { border-bottom: none; } +.zcash-v2 .history-card tr:hover td { background: rgba(255,255,255,0.015); } +.zcash-v2 .tx-kind { + display: inline-flex; align-items: center; gap: 8px; + color: var(--zk-fg-dim); +} +.zcash-v2 .tx-kind .pill { + font-family: var(--zk-font-mono); font-size: 9.5px; + letter-spacing: 0.1em; + padding: 2px 6px; border-radius: 2px; + text-transform: uppercase; +} +.zcash-v2 .tx-status { + display: inline-flex; align-items: center; gap: 6px; + font-size: 11.5px; + color: var(--zk-fg-mute); +} +.zcash-v2 .tx-status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; } +.zcash-v2 .tx-status.received::before { background: var(--zk-green); } +.zcash-v2 .tx-status.spent::before { background: var(--zk-fg-faint); } +.zcash-v2 .history-card .memo { + color: var(--zk-fg-mute); font-size: 11.5px; + display: block; max-width: 36ch; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.zcash-v2 .history-card .memo::before { content: "✉ "; color: var(--zk-gold); } + +/* TX column — explorer link */ +.zcash-v2 .history-card .explorer-link { + background: none; border: none; + font-family: var(--zk-font-display); font-size: 11.5px; + color: var(--zk-gold); cursor: pointer; + text-decoration: underline; text-decoration-color: transparent; + transition: text-decoration-color 0.15s; +} +.zcash-v2 .history-card .explorer-link:hover { text-decoration-color: var(--zk-gold); } + +/* Inline overview — quick actions + recent activity below balance card */ +.zcash-v2 .zk-inline-overview { + margin-bottom: 20px; +} +.zcash-v2 .zk-inline-overview .quick-actions { margin-bottom: 14px; } + +/* Compact refresh button inside status-pill */ +.zcash-v2 .status-pill .refresh-compact { + font-size: 15px; line-height: 1; + padding: 2px 4px !important; + opacity: 0.5; transition: opacity 0.15s; +} +.zcash-v2 .status-pill .refresh-compact:hover { opacity: 1; } + +/* ---- empty state ---- */ +.zcash-v2 .empty-card { + padding: 48px 28px; + border: 1px solid var(--zk-line); + border-radius: 10px; + background: var(--zk-bg-1); + text-align: center; +} +.zcash-v2 .empty-card h3 { + margin: 0 0 8px; + font-family: var(--zk-font-display); + font-size: 16px; font-weight: 600; +} +.zcash-v2 .empty-card p { margin: 0 auto; color: var(--zk-fg-dim); font-size: 13px; line-height: 1.55; max-width: 50ch; } + +/* ---- responsive ---- */ +@media (max-width: 1100px) { + .zcash-v2 .form-page.with-aside { grid-template-columns: 1fr; } + .zcash-v2 .receive-grid { grid-template-columns: 1fr; } + .zcash-v2 .quick-actions { grid-template-columns: 1fr; } +} +@media (max-width: 720px) { + /* Stack label above input on phones; the 80px label column gets cramped */ + .zcash-v2 .field { flex-wrap: wrap; row-gap: 4px; } + .zcash-v2 .field .lbl { width: 100%; } + .zcash-v2 .field input { flex: 1 0 60%; } + .zcash-v2 .submit-row { flex-direction: column; align-items: stretch; gap: 12px; } +} +` diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json index a51fa643..3331ae33 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json @@ -8,7 +8,9 @@ "initChoose": "Wählen Sie Ihre Einrichtungsmethode", "initProgress": "Wallet wird eingerichtet", "initLabel": "Benennen Sie Ihr Gerät", - "complete": "Einrichtung abgeschlossen!" + "complete": "Einrichtung abgeschlossen!", + "verifySeed": "Wiederherstellungsphrase prüfen", + "securityTips": "Sicherheitstipps" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Ihr KeepKey führt Sie durch den Update-Vorgang.", "bootloaderDetected": "Bootloader-Modus erkannt", "deviceReadyForUpdate": "Ihr KeepKey befindet sich im Bootloader-Modus und ist bereit für das Update.", - "updateBootloaderTo": "Bootloader auf v{{version}} aktualisieren" + "updateBootloaderTo": "Bootloader auf v{{version}} aktualisieren", + "doNotHoldButton": "Halten Sie beim erneuten Verbinden NICHT die Taste gedrückt. Schließen Sie das Gerät einfach normal an. Wenn Sie die Taste gedrückt halten, startet das Gerät wieder im Bootloader-Modus." }, "firmware": { "title": "Firmware-Update", @@ -107,13 +110,19 @@ "rebootTakingLong": "Die Wiederverbindung dauert länger als üblich...", "rebootTakingLongSub": "Das Gerät benötigt möglicherweise einen Moment zum Neustart.", "pleaseDisconnect": "Bitte trennen Sie Ihren KeepKey und schließen Sie ihn erneut an", - "disconnectMessage": "Ihr Gerät zeigt \u201EFirmware-Update abgeschlossen\u201C. Ziehen Sie das USB-Kabel ab und stecken Sie es wieder ein, um fortzufahren.", + "disconnectMessage": "Ihr Gerät zeigt „Firmware-Update abgeschlossen“. Ziehen Sie das USB-Kabel ab und stecken Sie es wieder ein, um fortzufahren.", "stillWaitingDisconnect": "Warte noch — stellen Sie sicher, dass Sie das USB-Kabel abziehen und wieder einstecken.", "manualReconnectTitle": "Gerät verbindet sich nicht wieder?", "manualReconnectStep1": "1. Trennen Sie Ihren KeepKey", "manualReconnectStep2": "2. Warten Sie 5 Sekunden", "manualReconnectStep3": "3. Schließen Sie ihn wieder an", - "manualReconnectNote": "Die Einrichtung wird automatisch fortgesetzt, sobald das Gerät erkannt wird." + "manualReconnectNote": "Die Einrichtung wird automatisch fortgesetzt, sobald das Gerät erkannt wird.", + "unsignedBootloaderWarning": "Sie installieren unsignierte Firmware (Entwickler-Firmware). Wenn auf diesem Gerät zuvor signierte (offizielle) Firmware lief, werden alle Daten gelöscht. Dies ist eine hardwareseitig erzwungene Sicherheitsgrenze; sie gilt auch beim Zurückwechseln zu signierter Firmware.", + "unsignedBootloaderAcknowledge": "Ich verstehe, dass dies das Gerät löschen kann, und ich habe meinen Seed gesichert", + "autoRebooting": "Ihr Gerät wird neu gestartet...", + "autoRebootingDetail": "Ihre KeepKey startet mit der neuen Firmware automatisch neu. Das dauert normalerweise nur wenige Sekunden.", + "waitingForDevice": "Warte auf Ihr Gerät...", + "wipeAndFlash": "Löschen & flashen" }, "initChoose": { "title": "Wallet einrichten", @@ -123,7 +132,12 @@ "createWallet": "Wallet erstellen", "recoverExistingWallet": "Bestehende Wallet wiederherstellen", "recoverDescription": "Geben Sie Ihre Wiederherstellungs-Seed-Phrase auf dem Gerät ein", - "recoverWallet": "Wallet wiederherstellen" + "recoverWallet": "Wallet wiederherstellen", + "seedLength": "Seed-Länge", + "words": "Wörter", + "entropyNote": "Eine längere Seed-Länge verbessert die Gesamtentropie der Wallet nicht.", + "learnMore": "Mehr erfahren", + "howManyWords": "Wie viele Wörter hat Ihr Seed?" }, "initProgress": { "creatingWallet": "Wallet wird erstellt...", @@ -133,7 +147,11 @@ "followPrompts": "Folgen Sie den Anweisungen auf Ihrem KeepKey-Bildschirm.", "lookAtDevice": "Schauen Sie auf Ihr KeepKey-Gerät und folgen Sie den Bildschirmanweisungen.", "failedToCreate": "Wallet-Erstellung fehlgeschlagen", - "failedToRecover": "Wallet-Wiederherstellung fehlgeschlagen" + "failedToRecover": "Wallet-Wiederherstellung fehlgeschlagen", + "writeDownWarning": "Schreiben Sie jedes Wort auf!", + "writeDownDetail": "Ihre Wiederherstellungsphrase wird auf dem Gerätebildschirm angezeigt. Schreiben Sie jedes Wort auf Papier. Dies ist Ihre EINZIGE Sicherung; Sie werden diese Wörter NICHT erneut sehen.", + "deviceLost": "Gerät getrennt. Schließen Sie es wieder an, um fortzufahren, oder gehen Sie zurück und versuchen Sie es erneut.", + "goBack": "Zurück" }, "initLabel": { "walletCreated": "Wallet erstellt!", @@ -155,6 +173,65 @@ "stepOf": "Schritt {{current}} von {{total}}", "settingUpWallet": "Wallet wird eingerichtet...", "previous": "Zurück", - "next": "Weiter" + "next": "Weiter", + "securityTips": "Sicherheitstipps" + }, + "verifySeed": { + "title": "Wiederherstellungsphrase prüfen", + "descriptionEmulator": "Wir fragen 3 zufällige Wörter aus Ihrem Seed ab, um zu bestätigen, dass Ihre Sicherung korrekt ist.", + "description": "Bestätigen Sie, dass Sie Ihre Wiederherstellungsphrase korrekt notiert haben. Ihr Gerät fordert Sie auf, einige Wörter einzugeben.", + "verifyNow": "Jetzt prüfen", + "skipForNow": "Überspringen; später prüfen", + "enterWords": "Geforderte Wörter eingeben", + "enterWordsDetail": "Geben Sie das richtige Wort für jede Position aus Ihrer Wiederherstellungsphrase ein.", + "checkWords": "Wörter prüfen", + "verifying": "Prüfe...", + "checkingAnswers": "Antworten werden geprüft...", + "followDevice": "Folgen Sie den Anweisungen auf Ihrer KeepKey, um die geforderten Wörter einzugeben.", + "verified": "Wiederherstellungsphrase geprüft!", + "verifiedDetail": "Ihre Sicherung ist korrekt. Bewahren Sie sie sicher auf und teilen Sie sie niemals mit anderen.", + "continue": "Fortfahren", + "failed": "Prüfung fehlgeschlagen", + "failedDetail": "Die eingegebenen Wörter stimmen nicht überein. Versuchen Sie es erneut oder prüfen Sie Ihre schriftliche Sicherung.", + "tryAgain": "Erneut versuchen" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Ihre PIN ist gemischt", + "body": "Ihre KeepKey zeigt ein zufällig angeordnetes Zahlenraster. Ordnen Sie die Positionen auf dem Bildschirm den Zahlen auf dem Gerät zu. Das Layout ändert sich jedes Mal, damit Beobachter Ihre PIN nicht stehlen können." + }, + "words": { + "title": "Ihre Wörter = Ihre Wallet", + "body": "Schreiben Sie Ihre 12/24 Wörter auf Papier. Bewahren Sie sie sicher auf. Tippen Sie sie niemals in einen Computer, eine Website oder ein Telefon ein. Wer diese Wörter hat, kontrolliert Ihre Guthaben." + }, + "recovery": { + "title": "Gemischte Wiederherstellungseingabe", + "body": "Bei der Wiederherstellung mischt KeepKey das Alphabet auf dem Gerätebildschirm. Sie geben Wörter nach Position ein, niemals durch Tippen der echten Buchstaben. Keylogger sehen nichts Nützliches." + }, + "deviceScreen": { + "title": "Vertrauen Sie dem Gerätebildschirm", + "body": "Bestätigen Sie vor dem Freigeben immer Adresse und Betrag auf Ihrer KeepKey. Ihr Computer kann kompromittiert sein; der Gerätebildschirm nicht. Besonders bei großen Transaktionen." + }, + "appConnections": { + "title": "App-Verbindungen sind aus", + "body": "Drittanbieter-Apps und dApps verbinden sich über die REST-API. Sie ist zu Ihrem Schutz standardmäßig deaktiviert. Aktivieren Sie sie in den Einstellungen nur bei Bedarf." + }, + "hiddenWallets": { + "title": "Versteckte Wallets (erweitert)", + "body": "Eine Passphrase erstellt aus demselben Seed eine separate versteckte Wallet. Wenn sie aktiviert ist, MÜSSEN Sie sie sich merken: Eine falsche Passphrase öffnet eine andere leere Wallet, keinen Fehler." + } + }, + "actions": { + "getStarted": "Loslegen", + "startUsing": "KeepKey verwenden", + "skipIntro": "Einführung überspringen", + "skipTips": "Tipps überspringen" + }, + "stepCounter": "{{current}} von {{total}}", + "walletLabels": { + "visible": "sichtbar", + "hidden": "versteckt" + } } } 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 16dda044..7b484d61 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json @@ -43,7 +43,17 @@ "txSent": "Transaction sent", "copyAddress": "Copy address", "copied": "Copied", - "zcashCliRequired": "Build zcash-cli to enable", + "viewOnDevice": "View on device", + "checkDevice": "Check device", + "verified": "Verified", + "viewShieldedAddressTooltip": "Derives and shows this Orchard address on your KeepKey device.", + "engineNotRunning": "Privacy Engine Not Running", + "engineNotRunningDesc": "The shielded privacy engine needs to be started to scan for private ZEC. It requires your KeepKey device to derive the viewing key.", + "startEngine": "Start Engine", + "startingEngine": "Starting privacy engine...", + "engineSetupOneTime": "One-time setup — requires your KeepKey device", + "engineError": "Engine failed to start", + "retryStartEngine": "Retry", "memoTooLong": "Memo exceeds 512 bytes", "invalidZcashRecipient": "Invalid address — must be u1... (unified) or t1.../t3... (transparent)", "shieldToOrchard": "Shield to Orchard", @@ -66,8 +76,11 @@ "memosBackfilled": "{{count}} memos fetched", "expandMemo": "Show memo", "collapseMemo": "Hide memo", + "outOfSync": "Out of Sync", + "synced": "Synced", "refresh": "Refresh", "refreshing": "Refreshing...", "needsScanPrompt": "Wallet has not been scanned yet. Scan the chain to find your shielded notes.", - "scanFromBlock": "Scan from block {{block}}" + "scanFromBlock": "Scan from block {{block}}", + "validatingWallet": "Validating shielded wallet against the chain..." } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json index bc736719..da9084ee 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json @@ -20,8 +20,8 @@ "tokenWarningDesc": "No token data was returned. Your native balances are shown but ERC-20 / token balances may be missing.", "refreshing": "Refreshing...", "reports": "Reports", - "pioneerOfflineTitle": "Balance server offline", - "pioneerOfflineDesc": "Unable to connect to {{url}}. Balances may be unavailable.", + "pioneerOfflineTitle": "Balance server unavailable", + "pioneerOfflineDesc": "Unable to refresh balances from {{url}}. Cached balances are kept when available.", "changeServer": "Change Server", "getSupport": "Get Support", "retry": "Retry", 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 9f23e637..ecb8f26d 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json @@ -68,8 +68,24 @@ "blindSigningRequired": "Blind Signing Required", "blindSigningDescription": "This transaction contains contract data the device cannot fully parse. Enable Advanced Mode to sign.", "enableNow": "Enable", + "enableAdvancedMode": "Enable Advanced Mode", "enableAdvancedModeFirst": "Enable Advanced Mode to approve", - "enableAdvancedModeHint": "Enable Advanced Mode above to unlock signing" + "enableAdvancedModeHint": "Enable Advanced Mode above to unlock signing", + "advancedModeEnableFailed": "Failed to enable Advanced Mode.", + "solanaAdvancedModeRequired": "Advanced Mode Required", + "solanaAdvancedModeDescription": "Advanced Mode is off, so your KeepKey will reject this raw Solana message. Enable Advanced Mode here before approving.", + "solanaUnsafeMessageTitle": "Unsafe Solana Message Signing", + "solanaUnsafeMessageDescription": "This dApp does not use KeepKey's safe Solana off-chain message format. Continue at your own risk.", + "solanaMessageLooksLikeTxTitle": "Unsafe Solana Message - Transaction-Like Payload", + "solanaMessageLooksLikeTxDescription": "This payload looks like Solana transaction data but was submitted as a raw message. Reject unless you fully trust this dApp.", + "solanaMessageToSign": "Message to Sign", + "solanaMessageKind": "Looks Like", + "solanaMessageText": "Text message", + "solanaMessageBinary": "Binary message", + "solanaMessageSerializedTx": "Serialized transaction", + "solanaMessageRawTxMessage": "Raw transaction message", + "solanaMessageNotUtf8": "Message is not readable UTF-8. Verify the raw bytes before approving.", + "solanaMessageRaw": "Raw bytes" }, "recovery": { "title": "Recover Your Wallet", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json index 46f7643c..4f471dcc 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json @@ -8,7 +8,9 @@ "initChoose": "Choose your setup method", "initProgress": "Setting up your wallet", "initLabel": "Name your device", - "complete": "Setup complete!" + "complete": "Setup complete!", + "verifySeed": "Verify your recovery phrase", + "securityTips": "Security tips" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Your KeepKey will guide you through the update process.", "bootloaderDetected": "Bootloader Mode Detected", "deviceReadyForUpdate": "Your KeepKey is in bootloader mode and ready for update.", - "updateBootloaderTo": "Update Bootloader to v{{version}}" + "updateBootloaderTo": "Update Bootloader to v{{version}}", + "doNotHoldButton": "DO NOT hold down the button when reconnecting! Just plug it in normally. Holding the button will put the device back into bootloader mode." }, "firmware": { "title": "Firmware Update", @@ -103,7 +106,7 @@ "size": "Size", "willWipeWarning": "Crossing the signed/unsigned firmware boundary will WIPE ALL DATA on the device. Make sure your recovery seed is backed up before proceeding.", "unsignedWarning": "This is unsigned developer firmware. It may cause unexpected behavior.", - "unsignedBootloaderWarning": "You are flashing unsigned (developer) firmware. If this device was previously running signed (official) firmware, all data will be erased. This is a hardware-enforced security boundary — the same applies when going back to signed firmware.", + "unsignedBootloaderWarning": "You are flashing unsigned (developer) firmware. If this device was previously running signed (official) firmware, all data will be erased. This is a hardware-enforced security boundary - the same applies when going back to signed firmware.", "unsignedBootloaderAcknowledge": "I understand this may wipe the device and I have my seed backed up", "customFlashFailed": "Custom firmware flash failed", "rebootTakingLong": "Reconnection is taking longer than usual...", @@ -115,7 +118,11 @@ "manualReconnectStep1": "1. Unplug your KeepKey", "manualReconnectStep2": "2. Wait 5 seconds", "manualReconnectStep3": "3. Plug it back in", - "manualReconnectNote": "Setup will continue automatically when the device is detected." + "manualReconnectNote": "Setup will continue automatically when the device is detected.", + "autoRebooting": "Your device is restarting...", + "autoRebootingDetail": "Your KeepKey will restart automatically with the new firmware. This usually takes a few seconds.", + "waitingForDevice": "Waiting for your device...", + "wipeAndFlash": "Wipe & Flash" }, "initChoose": { "title": "Set Up Your Wallet", @@ -125,7 +132,12 @@ "createWallet": "Create Wallet", "recoverExistingWallet": "Recover Existing Wallet", "recoverDescription": "Enter your recovery seed phrase on the device", - "recoverWallet": "Recover Wallet" + "recoverWallet": "Recover Wallet", + "seedLength": "Seed length", + "words": "words", + "entropyNote": "Added seed length does not improve overall wallet entropy.", + "learnMore": "Learn more", + "howManyWords": "How many words in your seed?" }, "initProgress": { "creatingWallet": "Creating Wallet...", @@ -135,7 +147,11 @@ "followPrompts": "Follow the prompts on your KeepKey device screen.", "lookAtDevice": "Look at your KeepKey device and follow the on-screen instructions.", "failedToCreate": "Failed to create wallet", - "failedToRecover": "Failed to recover wallet" + "failedToRecover": "Failed to recover wallet", + "writeDownWarning": "Write down every word!", + "writeDownDetail": "Your recovery phrase is showing on the device screen. Write each word on paper. This is your ONLY backup - you will NOT see these words again.", + "deviceLost": "Device disconnected. Plug it back in to continue, or go back to try again.", + "goBack": "Go Back" }, "initLabel": { "walletCreated": "Wallet Created!", @@ -157,6 +173,65 @@ "stepOf": "Step {{current}} of {{total}}", "settingUpWallet": "Setting up wallet...", "previous": "Previous", - "next": "Next" + "next": "Next", + "securityTips": "Security Tips" + }, + "verifySeed": { + "title": "Verify Your Recovery Phrase", + "descriptionEmulator": "We'll ask you for 3 random words from your seed to confirm you have a correct backup.", + "description": "Confirm that you wrote down your recovery phrase correctly. Your device will ask you to enter some of the words.", + "verifyNow": "Verify Now", + "skipForNow": "Skip - I'll verify later", + "enterWords": "Enter the requested words", + "enterWordsDetail": "Type the correct word for each position from your recovery phrase.", + "checkWords": "Check Words", + "verifying": "Verifying...", + "checkingAnswers": "Checking your answers...", + "followDevice": "Follow the prompts on your KeepKey to enter the requested words.", + "verified": "Recovery Phrase Verified!", + "verifiedDetail": "Your backup is correct. Keep it safe - never share it with anyone.", + "continue": "Continue", + "failed": "Verification Failed", + "failedDetail": "The words you entered did not match. Please try again or check your written backup.", + "tryAgain": "Try Again" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Your PIN is Scrambled", + "body": "Your KeepKey shows a randomized number grid. Match positions on screen to numbers on device. The layout changes every time so screen-watchers can't steal your PIN." + }, + "words": { + "title": "Your Words = Your Wallet", + "body": "Write your 12/24 words on paper. Store them somewhere safe. Never type them into a computer, website, or phone. Anyone with these words controls your funds." + }, + "recovery": { + "title": "Scrambled Recovery Entry", + "body": "When recovering, KeepKey scrambles the alphabet on the device screen. You enter words by position - never by typing actual letters. Keyloggers see nothing useful." + }, + "deviceScreen": { + "title": "Trust Your Device Screen", + "body": "Always confirm the address and amount on your KeepKey before approving. Your computer can be compromised; your device screen cannot. Especially for large transactions." + }, + "appConnections": { + "title": "App Connections Are Off", + "body": "Third-party apps and dApps connect via the REST API. It's disabled by default for your protection. Only enable it in Settings when you need it." + }, + "hiddenWallets": { + "title": "Hidden Wallets (Advanced)", + "body": "Passphrase creates a separate hidden wallet from the same seed. If enabled, you MUST remember it - a wrong passphrase opens a different empty wallet, not an error." + } + }, + "actions": { + "getStarted": "Get Started", + "startUsing": "Start Using KeepKey", + "skipIntro": "Skip intro", + "skipTips": "Skip tips" + }, + "stepCounter": "{{current}} of {{total}}", + "walletLabels": { + "visible": "visible", + "hidden": "hidden" + } } } 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 d42c887c..92412a3e 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/swap.json @@ -13,14 +13,66 @@ "noQuote": "No quote available", "expectedOutput": "Expected output", "minimumReceived": "Minimum received", + "expectedAfterFees": "Expected after fees", + "minimumAfterFeesSlippage": "Minimum receive after fees/slippage", "rate": "Rate", "networkFee": "Network fee", + "protocolFee": "Protocol fee", "slippage": "Slippage", + "slippageTolerance": "Slippage tolerance", + "quoteSlippage": "Quote slippage", "estimatedTime": "Est. time", "swap": "Swap", "reviewSwap": "Review Swap", - "review": "Review Swap", + "review": "Confirm Quote", "confirmSwap": "Confirm Swap", + "showDetails": "Show details", + "hideDetails": "Hide details", + "showPayload": "Show transaction payload", + "hidePayload": "Hide transaction payload", + "payloadPending": "Available after confirm", + "reviewApproval": "Review token approval", + "reviewApprovalDesc": "Approving the router to spend your tokens. Audit before signing.", + "reviewSwapTx": "Review swap transaction", + "reviewSwapTxDesc": "Confirm the exact payload that will be sent to your KeepKey.", + "approvalCompleted": "Token approval already broadcast", + "approveOnDevice": "Approve on Device", + "signOnDevice": "Sign on Device", + "reject": "Reject", + "approvalIrrevocableWarning": "Approving here broadcasts an on-chain allowance tx that consumes gas. If you cancel the swap step that follows, this approval cannot be reversed without sending another tx.", + "highSlippageWarning": "Slippage tolerance is high ({{pct}}%). You may receive less than expected.", + "refreshingQuote": "Refreshing quote...", + "deviceRejected": "Cancelled on device — no transaction was sent", + "reviewTimeout": "Review timed out — no transaction was sent", + "userRejectedTx": "You rejected the transaction. No funds moved.", + "approvalReverted": "Approval reverted on-chain — swap aborted to protect funds", + "insufficientGas": "Not enough gas in the source chain's native asset", + "nonceError": "Network error fetching nonce — retry in a moment", + "networkErrorSwap": "Network error — retry in a moment", + "quoteShiftedReReview": "Quote refreshed and dropped {{pct}}% — please review the new numbers and confirm again", + "previewFailed": "Build preview failed", + "buildingPayload": "Building payload...", + "payloadBuilding": "building payload", + "payloadReady": "payload ready", + "payloadFailed": "payload failed", + "payloadRequiredShort": "payload required", + "payloadRequiredButton": "Waiting for payload", + "payloadUnavailableButton": "Payload unavailable", + "payloadStillBuilding": "Transaction payload is still building. Review it before confirming.", + "payloadBuildFailed": "Transaction payload is not available: {{error}}", + "payloadRequired": "Transaction payload is required before confirming.", + "quoteRefreshedReviewPayload": "Quote refreshed. Review the rebuilt payload before confirming.", + "buildingPayloadDetail": "Building the exact transaction payload for review...", + "evmSummary": "EVM summary", + "approvalTx": "Approval transaction", + "swapTx": "Swap transaction", + "swapTxAfterApproval": "Swap transaction after approval", + "alreadyApproved": "Already approved · {{cur}} {{sym}} allowance to router", + "singleDevicePrompt": "1 device confirmation needed: swap", + "approvalNeeded": "Approval needed: {{req}} {{sym}}", + "currentAllowance": "Current allowance: {{cur}} {{sym}} → spender", + "twoDevicePrompts": "2 device confirmations needed: 1) approve {{sym}}, 2) swap. The approval consumes gas even if you cancel step 2.", + "approveAndSwap": "Approve & Swap", "signingOnDevice": "Confirm on device...", "broadcasting": "Broadcasting...", "broadcastingDesc": "Submitting your transaction to the network...", @@ -38,9 +90,12 @@ "connectDevice": "Connect device to swap", "errorQuote": "Failed to get quote", "errorSwap": "Swap failed", + "amountBelowMinimum": "Amount below minimum — try at least {{min}} {{symbol}}", + "amountBelowMinimumGeneric": "Amount below the minimum required by the swap pool — try a larger amount", "insufficientBalance": "Insufficient balance", "sameAsset": "Cannot swap the same asset", "routerContract": "Router", + "route": "Route", "vault": "Vault", "minutes": "min", "seconds": "sec", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json index 7172f153..9456a14c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json @@ -8,7 +8,9 @@ "initChoose": "Elige tu método de configuración", "initProgress": "Configurando tu billetera", "initLabel": "Nombra tu dispositivo", - "complete": "¡Configuración completada!" + "complete": "¡Configuración completada!", + "verifySeed": "Verifica tu frase de recuperación", + "securityTips": "Consejos de seguridad" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Tu KeepKey te guiará a través del proceso de actualización.", "bootloaderDetected": "Modo Bootloader detectado", "deviceReadyForUpdate": "Tu KeepKey está en modo bootloader y listo para la actualización.", - "updateBootloaderTo": "Actualizar Bootloader a v{{version}}" + "updateBootloaderTo": "Actualizar Bootloader a v{{version}}", + "doNotHoldButton": "NO mantengas presionado el botón al reconectar. Conéctalo normalmente. Mantener el botón presionado volverá a poner el dispositivo en modo bootloader." }, "firmware": { "title": "Actualización de Firmware", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Desconecta tu KeepKey", "manualReconnectStep2": "2. Espera 5 segundos", "manualReconnectStep3": "3. Conéctalo de nuevo", - "manualReconnectNote": "La configuración continuará automáticamente cuando se detecte el dispositivo." + "manualReconnectNote": "La configuración continuará automáticamente cuando se detecte el dispositivo.", + "unsignedBootloaderWarning": "Estás instalando firmware sin firmar (de desarrollador). Si este dispositivo antes ejecutaba firmware firmado (oficial), se borrarán todos los datos. Este es un límite de seguridad impuesto por hardware; lo mismo aplica al volver a firmware firmado.", + "unsignedBootloaderAcknowledge": "Entiendo que esto puede borrar el dispositivo y tengo respaldada mi semilla", + "autoRebooting": "Tu dispositivo se está reiniciando...", + "autoRebootingDetail": "Tu KeepKey se reiniciará automáticamente con el nuevo firmware. Esto suele tardar unos segundos.", + "waitingForDevice": "Esperando tu dispositivo...", + "wipeAndFlash": "Borrar e instalar" }, "initChoose": { "title": "Configura tu billetera", @@ -123,7 +132,12 @@ "createWallet": "Crear billetera", "recoverExistingWallet": "Recuperar billetera existente", "recoverDescription": "Ingresa tu frase semilla de recuperación en el dispositivo", - "recoverWallet": "Recuperar billetera" + "recoverWallet": "Recuperar billetera", + "seedLength": "Longitud de semilla", + "words": "palabras", + "entropyNote": "Agregar más palabras no mejora la entropía general de la billetera.", + "learnMore": "Más información", + "howManyWords": "¿Cuántas palabras tiene tu semilla?" }, "initProgress": { "creatingWallet": "Creando billetera...", @@ -133,7 +147,11 @@ "followPrompts": "Sigue las instrucciones en la pantalla de tu KeepKey.", "lookAtDevice": "Mira tu dispositivo KeepKey y sigue las instrucciones en pantalla.", "failedToCreate": "Error al crear la billetera", - "failedToRecover": "Error al recuperar la billetera" + "failedToRecover": "Error al recuperar la billetera", + "writeDownWarning": "¡Anota cada palabra!", + "writeDownDetail": "Tu frase de recuperación aparece en la pantalla del dispositivo. Escribe cada palabra en papel. Es tu ÚNICO respaldo; NO volverás a ver estas palabras.", + "deviceLost": "Dispositivo desconectado. Vuelve a conectarlo para continuar, o regresa para intentarlo de nuevo.", + "goBack": "Volver" }, "initLabel": { "walletCreated": "¡Billetera creada!", @@ -155,6 +173,65 @@ "stepOf": "Paso {{current}} de {{total}}", "settingUpWallet": "Configurando billetera...", "previous": "Anterior", - "next": "Siguiente" + "next": "Siguiente", + "securityTips": "Consejos de seguridad" + }, + "verifySeed": { + "title": "Verifica tu frase de recuperación", + "descriptionEmulator": "Te pediremos 3 palabras aleatorias de tu semilla para confirmar que tienes un respaldo correcto.", + "description": "Confirma que anotaste correctamente tu frase de recuperación. Tu dispositivo te pedirá introducir algunas palabras.", + "verifyNow": "Verificar ahora", + "skipForNow": "Omitir; verificaré después", + "enterWords": "Ingresa las palabras solicitadas", + "enterWordsDetail": "Escribe la palabra correcta para cada posición de tu frase de recuperación.", + "checkWords": "Comprobar palabras", + "verifying": "Verificando...", + "checkingAnswers": "Comprobando tus respuestas...", + "followDevice": "Sigue las instrucciones en tu KeepKey para ingresar las palabras solicitadas.", + "verified": "¡Frase de recuperación verificada!", + "verifiedDetail": "Tu respaldo es correcto. Guárdalo en un lugar seguro y nunca lo compartas con nadie.", + "continue": "Continuar", + "failed": "Verificación fallida", + "failedDetail": "Las palabras que ingresaste no coinciden. Inténtalo de nuevo o revisa tu respaldo escrito.", + "tryAgain": "Intentar de nuevo" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Tu PIN está mezclado", + "body": "Tu KeepKey muestra una cuadrícula numérica aleatoria. Haz coincidir las posiciones en pantalla con los números del dispositivo. El diseño cambia cada vez para que nadie pueda robar tu PIN mirando la pantalla." + }, + "words": { + "title": "Tus palabras = tu billetera", + "body": "Escribe tus 12/24 palabras en papel. Guárdalas en un lugar seguro. Nunca las escribas en una computadora, sitio web o teléfono. Quien tenga estas palabras controla tus fondos." + }, + "recovery": { + "title": "Entrada de recuperación mezclada", + "body": "Al recuperar, KeepKey mezcla el alfabeto en la pantalla del dispositivo. Ingresas palabras por posición, nunca escribiendo las letras reales. Los keyloggers no ven nada útil." + }, + "deviceScreen": { + "title": "Confía en la pantalla del dispositivo", + "body": "Confirma siempre la dirección y el monto en tu KeepKey antes de aprobar. Tu computadora puede estar comprometida; la pantalla del dispositivo no. Especialmente en transacciones grandes." + }, + "appConnections": { + "title": "Las conexiones de apps están desactivadas", + "body": "Las apps de terceros y dApps se conectan mediante la API REST. Está desactivada por defecto para protegerte. Actívala en Configuración solo cuando la necesites." + }, + "hiddenWallets": { + "title": "Billeteras ocultas (avanzado)", + "body": "La frase de contraseña crea una billetera oculta separada desde la misma semilla. Si la activas, DEBES recordarla: una frase incorrecta abre una billetera vacía diferente, no un error." + } + }, + "actions": { + "getStarted": "Comenzar", + "startUsing": "Comenzar a usar KeepKey", + "skipIntro": "Omitir introducción", + "skipTips": "Omitir consejos" + }, + "stepCounter": "{{current}} de {{total}}", + "walletLabels": { + "visible": "visible", + "hidden": "oculta" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json index 39e69efb..c20a243e 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json @@ -8,7 +8,9 @@ "initChoose": "Choisissez votre méthode de configuration", "initProgress": "Configuration de votre portefeuille", "initLabel": "Nommez votre appareil", - "complete": "Configuration terminée !" + "complete": "Configuration terminée !", + "verifySeed": "Vérifier votre phrase de récupération", + "securityTips": "Conseils de sécurité" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Votre KeepKey vous guidera tout au long du processus de mise à jour.", "bootloaderDetected": "Mode Bootloader détecté", "deviceReadyForUpdate": "Votre KeepKey est en mode bootloader et prêt pour la mise à jour.", - "updateBootloaderTo": "Mettre à jour le Bootloader vers v{{version}}" + "updateBootloaderTo": "Mettre à jour le Bootloader vers v{{version}}", + "doNotHoldButton": "NE maintenez PAS le bouton enfoncé lors de la reconnexion. Branchez simplement l’appareil normalement. Maintenir le bouton enfoncé remettra l’appareil en mode bootloader." }, "firmware": { "title": "Mise à jour du Firmware", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Débranchez votre KeepKey", "manualReconnectStep2": "2. Attendez 5 secondes", "manualReconnectStep3": "3. Rebranchez-le", - "manualReconnectNote": "La configuration continuera automatiquement lorsque l'appareil sera détecté." + "manualReconnectNote": "La configuration continuera automatiquement lorsque l'appareil sera détecté.", + "unsignedBootloaderWarning": "Vous installez un firmware non signé (développeur). Si cet appareil exécutait auparavant un firmware signé (officiel), toutes les données seront effacées. C’est une limite de sécurité imposée par le matériel; elle s’applique aussi lors du retour à un firmware signé.", + "unsignedBootloaderAcknowledge": "Je comprends que cela peut effacer l’appareil et j’ai sauvegardé ma graine", + "autoRebooting": "Votre appareil redémarre...", + "autoRebootingDetail": "Votre KeepKey redémarrera automatiquement avec le nouveau firmware. Cela prend généralement quelques secondes.", + "waitingForDevice": "En attente de votre appareil...", + "wipeAndFlash": "Effacer et flasher" }, "initChoose": { "title": "Configurez votre portefeuille", @@ -123,7 +132,12 @@ "createWallet": "Créer le portefeuille", "recoverExistingWallet": "Récupérer un portefeuille existant", "recoverDescription": "Entrez votre phrase de récupération sur l'appareil", - "recoverWallet": "Récupérer le portefeuille" + "recoverWallet": "Récupérer le portefeuille", + "seedLength": "Longueur de la graine", + "words": "mots", + "entropyNote": "Ajouter des mots n’améliore pas l’entropie globale du portefeuille.", + "learnMore": "En savoir plus", + "howManyWords": "Combien de mots contient votre graine ?" }, "initProgress": { "creatingWallet": "Création du portefeuille...", @@ -133,7 +147,11 @@ "followPrompts": "Suivez les instructions sur l'écran de votre KeepKey.", "lookAtDevice": "Regardez votre KeepKey et suivez les instructions à l'écran.", "failedToCreate": "Échec de la création du portefeuille", - "failedToRecover": "Échec de la récupération du portefeuille" + "failedToRecover": "Échec de la récupération du portefeuille", + "writeDownWarning": "Notez chaque mot !", + "writeDownDetail": "Votre phrase de récupération s’affiche sur l’écran de l’appareil. Écrivez chaque mot sur papier. C’est votre SEULE sauvegarde; vous ne reverrez PAS ces mots.", + "deviceLost": "Appareil déconnecté. Rebranchez-le pour continuer, ou revenez en arrière pour réessayer.", + "goBack": "Retour" }, "initLabel": { "walletCreated": "Portefeuille créé !", @@ -155,6 +173,65 @@ "stepOf": "Étape {{current}} sur {{total}}", "settingUpWallet": "Configuration du portefeuille...", "previous": "Précédent", - "next": "Suivant" + "next": "Suivant", + "securityTips": "Conseils de sécurité" + }, + "verifySeed": { + "title": "Vérifier votre phrase de récupération", + "descriptionEmulator": "Nous vous demanderons 3 mots aléatoires de votre graine pour confirmer que votre sauvegarde est correcte.", + "description": "Confirmez que vous avez correctement noté votre phrase de récupération. Votre appareil vous demandera de saisir certains mots.", + "verifyNow": "Vérifier maintenant", + "skipForNow": "Ignorer; je vérifierai plus tard", + "enterWords": "Saisissez les mots demandés", + "enterWordsDetail": "Tapez le mot correct pour chaque position de votre phrase de récupération.", + "checkWords": "Vérifier les mots", + "verifying": "Vérification...", + "checkingAnswers": "Vérification de vos réponses...", + "followDevice": "Suivez les instructions sur votre KeepKey pour saisir les mots demandés.", + "verified": "Phrase de récupération vérifiée !", + "verifiedDetail": "Votre sauvegarde est correcte. Gardez-la en sécurité et ne la partagez jamais.", + "continue": "Continuer", + "failed": "Échec de la vérification", + "failedDetail": "Les mots saisis ne correspondent pas. Réessayez ou vérifiez votre sauvegarde écrite.", + "tryAgain": "Réessayer" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Votre PIN est mélangé", + "body": "Votre KeepKey affiche une grille de chiffres aléatoire. Faites correspondre les positions à l’écran avec les chiffres sur l’appareil. La disposition change à chaque fois afin qu’un observateur ne puisse pas voler votre PIN." + }, + "words": { + "title": "Vos mots = votre portefeuille", + "body": "Écrivez vos 12/24 mots sur papier. Conservez-les en lieu sûr. Ne les saisissez jamais sur un ordinateur, un site web ou un téléphone. Toute personne qui possède ces mots contrôle vos fonds." + }, + "recovery": { + "title": "Saisie de récupération mélangée", + "body": "Lors de la récupération, KeepKey mélange l’alphabet sur l’écran de l’appareil. Vous saisissez les mots par position, jamais en tapant les vraies lettres. Les enregistreurs de frappe ne voient rien d’utile." + }, + "deviceScreen": { + "title": "Fiez-vous à l’écran de l’appareil", + "body": "Confirmez toujours l’adresse et le montant sur votre KeepKey avant d’approuver. Votre ordinateur peut être compromis; l’écran de votre appareil ne l’est pas. Surtout pour les grosses transactions." + }, + "appConnections": { + "title": "Les connexions d’apps sont désactivées", + "body": "Les apps tierces et dApps se connectent via l’API REST. Elle est désactivée par défaut pour votre protection. Activez-la dans Paramètres uniquement lorsque vous en avez besoin." + }, + "hiddenWallets": { + "title": "Portefeuilles cachés (avancé)", + "body": "La phrase secrète crée un portefeuille caché séparé à partir de la même graine. Si elle est activée, vous DEVEZ vous en souvenir: une mauvaise phrase ouvre un autre portefeuille vide, pas une erreur." + } + }, + "actions": { + "getStarted": "Commencer", + "startUsing": "Commencer à utiliser KeepKey", + "skipIntro": "Ignorer l’introduction", + "skipTips": "Ignorer les conseils" + }, + "stepCounter": "{{current}} sur {{total}}", + "walletLabels": { + "visible": "visible", + "hidden": "caché" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json index c845bf6a..a9b9557a 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json @@ -8,7 +8,9 @@ "initChoose": "Scegli il metodo di configurazione", "initProgress": "Configurazione del portafoglio", "initLabel": "Dai un nome al dispositivo", - "complete": "Configurazione completata!" + "complete": "Configurazione completata!", + "verifySeed": "Verifica la frase di recupero", + "securityTips": "Suggerimenti di sicurezza" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Il tuo KeepKey ti guiderà attraverso il processo di aggiornamento.", "bootloaderDetected": "Modalità Bootloader rilevata", "deviceReadyForUpdate": "Il tuo KeepKey è in modalità bootloader e pronto per l'aggiornamento.", - "updateBootloaderTo": "Aggiorna Bootloader a v{{version}}" + "updateBootloaderTo": "Aggiorna Bootloader a v{{version}}", + "doNotHoldButton": "NON tenere premuto il pulsante durante la riconnessione. Collega semplicemente il dispositivo normalmente. Tenere premuto il pulsante rimetterà il dispositivo in modalità bootloader." }, "firmware": { "title": "Aggiornamento Firmware", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Scollega il tuo KeepKey", "manualReconnectStep2": "2. Attendi 5 secondi", "manualReconnectStep3": "3. Ricollegalo", - "manualReconnectNote": "La configurazione continuerà automaticamente quando il dispositivo verrà rilevato." + "manualReconnectNote": "La configurazione continuerà automaticamente quando il dispositivo verrà rilevato.", + "unsignedBootloaderWarning": "Stai installando firmware non firmato (sviluppatore). Se questo dispositivo eseguiva firmware firmato (ufficiale), tutti i dati verranno cancellati. È un limite di sicurezza imposto dall’hardware; vale anche quando si torna a firmware firmato.", + "unsignedBootloaderAcknowledge": "Capisco che questo può cancellare il dispositivo e ho salvato il mio seed", + "autoRebooting": "Il dispositivo si sta riavviando...", + "autoRebootingDetail": "Il tuo KeepKey si riavvierà automaticamente con il nuovo firmware. Di solito richiede pochi secondi.", + "waitingForDevice": "In attesa del dispositivo...", + "wipeAndFlash": "Cancella e installa" }, "initChoose": { "title": "Configura il tuo portafoglio", @@ -123,7 +132,12 @@ "createWallet": "Crea portafoglio", "recoverExistingWallet": "Recupera portafoglio esistente", "recoverDescription": "Inserisci la tua frase seed di recupero sul dispositivo", - "recoverWallet": "Recupera portafoglio" + "recoverWallet": "Recupera portafoglio", + "seedLength": "Lunghezza seed", + "words": "parole", + "entropyNote": "Aumentare la lunghezza del seed non migliora l’entropia complessiva del wallet.", + "learnMore": "Scopri di più", + "howManyWords": "Quante parole ci sono nel tuo seed?" }, "initProgress": { "creatingWallet": "Creazione portafoglio...", @@ -133,7 +147,11 @@ "followPrompts": "Segui le istruzioni sullo schermo del tuo KeepKey.", "lookAtDevice": "Guarda il tuo dispositivo KeepKey e segui le istruzioni sullo schermo.", "failedToCreate": "Creazione portafoglio fallita", - "failedToRecover": "Recupero portafoglio fallito" + "failedToRecover": "Recupero portafoglio fallito", + "writeDownWarning": "Annota ogni parola!", + "writeDownDetail": "La frase di recupero è mostrata sullo schermo del dispositivo. Scrivi ogni parola su carta. È il tuo UNICO backup; NON vedrai più queste parole.", + "deviceLost": "Dispositivo disconnesso. Ricollegalo per continuare oppure torna indietro per riprovare.", + "goBack": "Indietro" }, "initLabel": { "walletCreated": "Portafoglio creato!", @@ -155,6 +173,65 @@ "stepOf": "Passo {{current}} di {{total}}", "settingUpWallet": "Configurazione portafoglio...", "previous": "Precedente", - "next": "Avanti" + "next": "Avanti", + "securityTips": "Suggerimenti di sicurezza" + }, + "verifySeed": { + "title": "Verifica la frase di recupero", + "descriptionEmulator": "Ti chiederemo 3 parole casuali del seed per confermare che il backup sia corretto.", + "description": "Conferma di aver scritto correttamente la frase di recupero. Il dispositivo ti chiederà di inserire alcune parole.", + "verifyNow": "Verifica ora", + "skipForNow": "Salta; verificherò più tardi", + "enterWords": "Inserisci le parole richieste", + "enterWordsDetail": "Digita la parola corretta per ogni posizione della frase di recupero.", + "checkWords": "Controlla parole", + "verifying": "Verifica...", + "checkingAnswers": "Controllo delle risposte...", + "followDevice": "Segui le istruzioni sul KeepKey per inserire le parole richieste.", + "verified": "Frase di recupero verificata!", + "verifiedDetail": "Il backup è corretto. Conservalo al sicuro e non condividerlo mai.", + "continue": "Continua", + "failed": "Verifica non riuscita", + "failedDetail": "Le parole inserite non corrispondono. Riprova o controlla il backup scritto.", + "tryAgain": "Riprova" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Il tuo PIN è mescolato", + "body": "Il tuo KeepKey mostra una griglia numerica casuale. Abbina le posizioni sullo schermo ai numeri sul dispositivo. Il layout cambia ogni volta così chi osserva non può rubare il PIN." + }, + "words": { + "title": "Le tue parole = il tuo wallet", + "body": "Scrivi le tue 12/24 parole su carta. Conservale in un posto sicuro. Non digitarle mai in computer, siti web o telefoni. Chiunque abbia queste parole controlla i tuoi fondi." + }, + "recovery": { + "title": "Inserimento di recupero mescolato", + "body": "Durante il recupero, KeepKey mescola l’alfabeto sullo schermo del dispositivo. Inserisci le parole per posizione, mai digitando le lettere reali. I keylogger non vedono nulla di utile." + }, + "deviceScreen": { + "title": "Fidati dello schermo del dispositivo", + "body": "Conferma sempre indirizzo e importo sul KeepKey prima di approvare. Il computer può essere compromesso; lo schermo del dispositivo no. Soprattutto per transazioni grandi." + }, + "appConnections": { + "title": "Le connessioni app sono disattivate", + "body": "App di terze parti e dApp si collegano tramite API REST. È disattivata per impostazione predefinita per proteggerti. Attivala nelle Impostazioni solo quando serve." + }, + "hiddenWallets": { + "title": "Wallet nascosti (avanzato)", + "body": "La passphrase crea un wallet nascosto separato dallo stesso seed. Se abilitata, DEVI ricordarla: una passphrase errata apre un wallet vuoto diverso, non un errore." + } + }, + "actions": { + "getStarted": "Inizia", + "startUsing": "Inizia a usare KeepKey", + "skipIntro": "Salta introduzione", + "skipTips": "Salta suggerimenti" + }, + "stepCounter": "{{current}} di {{total}}", + "walletLabels": { + "visible": "visibile", + "hidden": "nascosto" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json index 3309b89b..89e1c97f 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json @@ -8,7 +8,9 @@ "initChoose": "セットアップ方法を選択", "initProgress": "ウォレットを設定中", "initLabel": "デバイスに名前を付ける", - "complete": "セットアップ完了!" + "complete": "セットアップ完了!", + "verifySeed": "リカバリーフレーズを確認", + "securityTips": "セキュリティのヒント" }, "visibleSteps": { "bootloader": "ブートローダー", @@ -62,7 +64,8 @@ "deviceWillGuide": "KeepKeyが更新プロセスをガイドします。", "bootloaderDetected": "ブートローダーモードを検出しました", "deviceReadyForUpdate": "KeepKeyはブートローダーモードで、更新の準備ができています。", - "updateBootloaderTo": "ブートローダーをv{{version}}に更新" + "updateBootloaderTo": "ブートローダーをv{{version}}に更新", + "doNotHoldButton": "再接続するときにボタンを押し続けないでください。通常どおり接続してください。ボタンを押したままにすると、デバイスが再びブートローダーモードになります。" }, "firmware": { "title": "ファームウェアの更新", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. KeepKeyを抜いてください", "manualReconnectStep2": "2. 5秒待ってください", "manualReconnectStep3": "3. もう一度接続してください", - "manualReconnectNote": "デバイスが検出されると、セットアップは自動的に続行されます。" + "manualReconnectNote": "デバイスが検出されると、セットアップは自動的に続行されます。", + "unsignedBootloaderWarning": "未署名の開発者向けファームウェアをインストールしています。このデバイスで以前に署名済みの公式ファームウェアを実行していた場合、すべてのデータが消去されます。これはハードウェアで強制されるセキュリティ境界であり、署名済みファームウェアへ戻る場合も同じです。", + "unsignedBootloaderAcknowledge": "これによりデバイスが消去される可能性があり、seed をバックアップ済みであることを理解しています", + "autoRebooting": "デバイスを再起動しています...", + "autoRebootingDetail": "KeepKey は新しいファームウェアで自動的に再起動します。通常は数秒で完了します。", + "waitingForDevice": "デバイスを待機しています...", + "wipeAndFlash": "消去して書き込み" }, "initChoose": { "title": "ウォレットをセットアップ", @@ -123,7 +132,12 @@ "createWallet": "ウォレットを作成", "recoverExistingWallet": "既存のウォレットを復元", "recoverDescription": "デバイスでリカバリーシードフレーズを入力", - "recoverWallet": "ウォレットを復元" + "recoverWallet": "ウォレットを復元", + "seedLength": "seed の長さ", + "words": "語", + "entropyNote": "seed の長さを増やしても、ウォレット全体のエントロピーは向上しません。", + "learnMore": "詳細を見る", + "howManyWords": "seed は何語ですか?" }, "initProgress": { "creatingWallet": "ウォレットを作成中...", @@ -133,7 +147,11 @@ "followPrompts": "KeepKeyデバイスの画面の指示に従ってください。", "lookAtDevice": "KeepKeyデバイスを見て、画面の指示に従ってください。", "failedToCreate": "ウォレットの作成に失敗しました", - "failedToRecover": "ウォレットの復元に失敗しました" + "failedToRecover": "ウォレットの復元に失敗しました", + "writeDownWarning": "すべての単語を書き留めてください!", + "writeDownDetail": "リカバリーフレーズがデバイス画面に表示されています。各単語を紙に書き留めてください。これは唯一のバックアップです。この単語は再表示されません。", + "deviceLost": "デバイスが切断されました。続行するには再接続するか、戻って再試行してください。", + "goBack": "戻る" }, "initLabel": { "walletCreated": "ウォレットが作成されました!", @@ -155,6 +173,65 @@ "stepOf": "ステップ {{current}} / {{total}}", "settingUpWallet": "ウォレットを設定中...", "previous": "前へ", - "next": "次へ" + "next": "次へ", + "securityTips": "セキュリティのヒント" + }, + "verifySeed": { + "title": "リカバリーフレーズを確認", + "descriptionEmulator": "正しいバックアップがあることを確認するため、seed からランダムに3語を尋ねます。", + "description": "リカバリーフレーズを正しく書き留めたことを確認します。デバイスがいくつかの単語の入力を求めます。", + "verifyNow": "今すぐ確認", + "skipForNow": "スキップして後で確認", + "enterWords": "要求された単語を入力", + "enterWordsDetail": "リカバリーフレーズの各位置に対応する正しい単語を入力してください。", + "checkWords": "単語を確認", + "verifying": "確認中...", + "checkingAnswers": "回答を確認しています...", + "followDevice": "KeepKey の指示に従って、要求された単語を入力してください。", + "verified": "リカバリーフレーズを確認しました!", + "verifiedDetail": "バックアップは正しいです。安全に保管し、誰にも共有しないでください。", + "continue": "続行", + "failed": "確認に失敗しました", + "failedDetail": "入力した単語が一致しません。もう一度試すか、書き留めたバックアップを確認してください。", + "tryAgain": "再試行" + }, + "tutorial": { + "cards": { + "pin": { + "title": "PIN はシャッフルされます", + "body": "KeepKey はランダムな数字グリッドを表示します。画面上の位置をデバイス上の数字に対応させてください。配置は毎回変わるため、画面を見られても PIN を盗まれにくくなります。" + }, + "words": { + "title": "単語 = ウォレット", + "body": "12/24語を紙に書き留め、安全な場所に保管してください。コンピューター、Webサイト、電話には絶対に入力しないでください。これらの単語を持つ人は資金を管理できます。" + }, + "recovery": { + "title": "シャッフルされた復元入力", + "body": "復元時、KeepKey はデバイス画面のアルファベットをシャッフルします。実際の文字を入力するのではなく、位置で単語を入力します。キーロガーには有用な情報が残りません。" + }, + "deviceScreen": { + "title": "デバイス画面を信頼する", + "body": "承認前に、KeepKey 上で必ずアドレスと金額を確認してください。コンピューターは侵害される可能性がありますが、デバイス画面は信頼できます。特に大きな取引では重要です。" + }, + "appConnections": { + "title": "アプリ接続はオフです", + "body": "サードパーティアプリと dApp は REST API 経由で接続します。保護のため既定では無効です。必要なときだけ設定で有効にしてください。" + }, + "hiddenWallets": { + "title": "隠しウォレット(上級)", + "body": "パスフレーズは同じ seed から別の隠しウォレットを作成します。有効にした場合は必ず覚えてください。間違ったパスフレーズはエラーではなく、別の空のウォレットを開きます。" + } + }, + "actions": { + "getStarted": "開始", + "startUsing": "KeepKey を使い始める", + "skipIntro": "紹介をスキップ", + "skipTips": "ヒントをスキップ" + }, + "stepCounter": "{{current}} / {{total}}", + "walletLabels": { + "visible": "表示", + "hidden": "非表示" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json index a4b4facb..c2d029a5 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json @@ -8,7 +8,9 @@ "initChoose": "설정 방법 선택", "initProgress": "지갑 설정 중", "initLabel": "기기 이름 지정", - "complete": "설정 완료!" + "complete": "설정 완료!", + "verifySeed": "복구 문구 확인", + "securityTips": "보안 팁" }, "visibleSteps": { "bootloader": "부트로더", @@ -62,7 +64,8 @@ "deviceWillGuide": "KeepKey가 업데이트 과정을 안내합니다.", "bootloaderDetected": "부트로더 모드 감지됨", "deviceReadyForUpdate": "KeepKey가 부트로더 모드에 있으며 업데이트 준비가 되었습니다.", - "updateBootloaderTo": "부트로더를 v{{version}}(으)로 업데이트" + "updateBootloaderTo": "부트로더를 v{{version}}(으)로 업데이트", + "doNotHoldButton": "다시 연결할 때 버튼을 누르고 있지 마세요. 그냥 정상적으로 연결하세요. 버튼을 누르고 있으면 장치가 다시 부트로더 모드로 들어갑니다." }, "firmware": { "title": "펌웨어 업데이트", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. KeepKey를 분리하세요", "manualReconnectStep2": "2. 5초간 기다리세요", "manualReconnectStep3": "3. 다시 연결하세요", - "manualReconnectNote": "기기가 감지되면 설정이 자동으로 계속됩니다." + "manualReconnectNote": "기기가 감지되면 설정이 자동으로 계속됩니다.", + "unsignedBootloaderWarning": "서명되지 않은 개발자 펌웨어를 설치하고 있습니다. 이 장치가 이전에 서명된 공식 펌웨어를 실행했다면 모든 데이터가 삭제됩니다. 이는 하드웨어로 강제되는 보안 경계이며, 서명된 펌웨어로 되돌아갈 때도 동일하게 적용됩니다.", + "unsignedBootloaderAcknowledge": "이 작업으로 장치가 지워질 수 있음을 이해하며 seed를 백업했습니다", + "autoRebooting": "장치가 다시 시작되는 중...", + "autoRebootingDetail": "KeepKey가 새 펌웨어로 자동 재시작됩니다. 보통 몇 초 정도 걸립니다.", + "waitingForDevice": "장치를 기다리는 중...", + "wipeAndFlash": "초기화 후 설치" }, "initChoose": { "title": "지갑 설정", @@ -123,7 +132,12 @@ "createWallet": "지갑 만들기", "recoverExistingWallet": "기존 지갑 복구", "recoverDescription": "기기에서 복구 시드 구문 입력", - "recoverWallet": "지갑 복구" + "recoverWallet": "지갑 복구", + "seedLength": "Seed 길이", + "words": "단어", + "entropyNote": "Seed 길이를 늘려도 전체 지갑 엔트로피는 향상되지 않습니다.", + "learnMore": "자세히 알아보기", + "howManyWords": "Seed는 몇 단어인가요?" }, "initProgress": { "creatingWallet": "지갑 생성 중...", @@ -133,7 +147,11 @@ "followPrompts": "KeepKey 기기 화면의 안내를 따르세요.", "lookAtDevice": "KeepKey 기기를 보고 화면의 지시를 따르세요.", "failedToCreate": "지갑 생성 실패", - "failedToRecover": "지갑 복구 실패" + "failedToRecover": "지갑 복구 실패", + "writeDownWarning": "모든 단어를 적어 두세요!", + "writeDownDetail": "복구 문구가 장치 화면에 표시되고 있습니다. 각 단어를 종이에 적으세요. 이것이 유일한 백업이며, 이 단어들은 다시 표시되지 않습니다.", + "deviceLost": "장치 연결이 끊어졌습니다. 계속하려면 다시 연결하거나, 뒤로 돌아가 다시 시도하세요.", + "goBack": "뒤로" }, "initLabel": { "walletCreated": "지갑이 생성되었습니다!", @@ -155,6 +173,65 @@ "stepOf": "{{total}}단계 중 {{current}}단계", "settingUpWallet": "지갑 설정 중...", "previous": "이전", - "next": "다음" + "next": "다음", + "securityTips": "보안 팁" + }, + "verifySeed": { + "title": "복구 문구 확인", + "descriptionEmulator": "백업이 올바른지 확인하기 위해 seed에서 임의의 3개 단어를 묻겠습니다.", + "description": "복구 문구를 정확히 적었는지 확인하세요. 장치가 일부 단어 입력을 요청합니다.", + "verifyNow": "지금 확인", + "skipForNow": "건너뛰기; 나중에 확인", + "enterWords": "요청된 단어 입력", + "enterWordsDetail": "복구 문구의 각 위치에 맞는 올바른 단어를 입력하세요.", + "checkWords": "단어 확인", + "verifying": "확인 중...", + "checkingAnswers": "답변 확인 중...", + "followDevice": "요청된 단어를 입력하려면 KeepKey의 안내를 따르세요.", + "verified": "복구 문구 확인 완료!", + "verifiedDetail": "백업이 올바릅니다. 안전하게 보관하고 누구와도 공유하지 마세요.", + "continue": "계속", + "failed": "확인 실패", + "failedDetail": "입력한 단어가 일치하지 않습니다. 다시 시도하거나 적어 둔 백업을 확인하세요.", + "tryAgain": "다시 시도" + }, + "tutorial": { + "cards": { + "pin": { + "title": "PIN은 섞여 표시됩니다", + "body": "KeepKey는 무작위 숫자 격자를 보여줍니다. 화면의 위치를 장치의 숫자와 맞추세요. 배열은 매번 바뀌므로 화면을 보는 사람이 PIN을 훔칠 수 없습니다." + }, + "words": { + "title": "단어 = 지갑", + "body": "12/24개 단어를 종이에 적고 안전한 곳에 보관하세요. 컴퓨터, 웹사이트, 휴대폰에 절대 입력하지 마세요. 이 단어를 가진 사람은 자금을 제어할 수 있습니다." + }, + "recovery": { + "title": "섞인 복구 입력", + "body": "복구할 때 KeepKey는 장치 화면의 알파벳을 섞습니다. 실제 글자를 입력하지 않고 위치로 단어를 입력합니다. 키로거에는 유용한 정보가 남지 않습니다." + }, + "deviceScreen": { + "title": "장치 화면을 신뢰하세요", + "body": "승인하기 전에 항상 KeepKey에서 주소와 금액을 확인하세요. 컴퓨터는 손상될 수 있지만 장치 화면은 신뢰할 수 있습니다. 특히 큰 거래에서 중요합니다." + }, + "appConnections": { + "title": "앱 연결은 꺼져 있습니다", + "body": "타사 앱과 dApp은 REST API로 연결됩니다. 보호를 위해 기본적으로 비활성화되어 있습니다. 필요할 때만 설정에서 활성화하세요." + }, + "hiddenWallets": { + "title": "숨겨진 지갑(고급)", + "body": "Passphrase는 같은 seed에서 별도의 숨겨진 지갑을 만듭니다. 활성화하면 반드시 기억해야 합니다. 잘못된 passphrase는 오류가 아니라 다른 빈 지갑을 엽니다." + } + }, + "actions": { + "getStarted": "시작하기", + "startUsing": "KeepKey 사용 시작", + "skipIntro": "소개 건너뛰기", + "skipTips": "팁 건너뛰기" + }, + "stepCounter": "{{current}} / {{total}}", + "walletLabels": { + "visible": "표시", + "hidden": "숨김" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/nl/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/nl/setup.json index 5266bfc2..55c3eabf 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/nl/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/nl/setup.json @@ -8,7 +8,9 @@ "initChoose": "Kies installatiemethode", "initProgress": "Wallet configureren", "initLabel": "Geef je apparaat een naam", - "complete": "Installatie voltooid!" + "complete": "Installatie voltooid!", + "verifySeed": "Herstelzin controleren", + "securityTips": "Beveiligingstips" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Je KeepKey begeleidt je door het updateproces.", "bootloaderDetected": "Bootloadermodus gedetecteerd", "deviceReadyForUpdate": "Je KeepKey is in bootloadermodus en klaar voor de update.", - "updateBootloaderTo": "Bootloader bijwerken naar v{{version}}" + "updateBootloaderTo": "Bootloader bijwerken naar v{{version}}", + "doNotHoldButton": "Houd de knop NIET ingedrukt bij het opnieuw aansluiten. Sluit het apparaat gewoon normaal aan. Als u de knop ingedrukt houdt, komt het apparaat weer in bootloader-modus." }, "firmware": { "title": "Firmware-update", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Koppel je KeepKey los", "manualReconnectStep2": "2. Wacht 5 seconden", "manualReconnectStep3": "3. Sluit opnieuw aan", - "manualReconnectNote": "De installatie gaat automatisch verder zodra het apparaat wordt gedetecteerd." + "manualReconnectNote": "De installatie gaat automatisch verder zodra het apparaat wordt gedetecteerd.", + "unsignedBootloaderWarning": "U installeert niet-ondertekende firmware (ontwikkelaarsfirmware). Als dit apparaat eerder ondertekende (officiële) firmware draaide, worden alle gegevens gewist. Dit is een door hardware afgedwongen beveiligingsgrens; hetzelfde geldt bij teruggaan naar ondertekende firmware.", + "unsignedBootloaderAcknowledge": "Ik begrijp dat dit het apparaat kan wissen en ik heb mijn seed geback-upt", + "autoRebooting": "Uw apparaat wordt opnieuw opgestart...", + "autoRebootingDetail": "Uw KeepKey start automatisch opnieuw op met de nieuwe firmware. Dit duurt meestal enkele seconden.", + "waitingForDevice": "Wachten op uw apparaat...", + "wipeAndFlash": "Wissen en flashen" }, "initChoose": { "title": "Stel je wallet in", @@ -123,7 +132,12 @@ "createWallet": "Wallet aanmaken", "recoverExistingWallet": "Bestaande wallet herstellen", "recoverDescription": "Voer je herstelzin in op het apparaat", - "recoverWallet": "Wallet herstellen" + "recoverWallet": "Wallet herstellen", + "seedLength": "Seed-lengte", + "words": "woorden", + "entropyNote": "Extra seed-lengte verbetert de totale wallet-entropie niet.", + "learnMore": "Meer informatie", + "howManyWords": "Hoeveel woorden zitten er in uw seed?" }, "initProgress": { "creatingWallet": "Wallet aanmaken...", @@ -133,7 +147,11 @@ "followPrompts": "Volg de aanwijzingen op het scherm van je KeepKey.", "lookAtDevice": "Kijk naar je KeepKey-apparaat en volg de instructies op het scherm.", "failedToCreate": "Wallet aanmaken mislukt", - "failedToRecover": "Wallet herstellen mislukt" + "failedToRecover": "Wallet herstellen mislukt", + "writeDownWarning": "Schrijf elk woord op!", + "writeDownDetail": "Uw herstelzin wordt op het apparaatscherm weergegeven. Schrijf elk woord op papier. Dit is uw ENIGE back-up; u zult deze woorden NIET opnieuw zien.", + "deviceLost": "Apparaat losgekoppeld. Sluit het opnieuw aan om door te gaan, of ga terug om het opnieuw te proberen.", + "goBack": "Terug" }, "initLabel": { "walletCreated": "Wallet aangemaakt!", @@ -155,6 +173,65 @@ "stepOf": "Stap {{current}} van {{total}}", "settingUpWallet": "Wallet instellen...", "previous": "Vorige", - "next": "Volgende" + "next": "Volgende", + "securityTips": "Beveiligingstips" + }, + "verifySeed": { + "title": "Herstelzin controleren", + "descriptionEmulator": "We vragen om 3 willekeurige woorden uit uw seed om te bevestigen dat uw back-up klopt.", + "description": "Bevestig dat u uw herstelzin correct hebt opgeschreven. Uw apparaat vraagt u enkele woorden in te voeren.", + "verifyNow": "Nu controleren", + "skipForNow": "Overslaan; later controleren", + "enterWords": "Voer de gevraagde woorden in", + "enterWordsDetail": "Typ het juiste woord voor elke positie uit uw herstelzin.", + "checkWords": "Woorden controleren", + "verifying": "Controleren...", + "checkingAnswers": "Antwoorden controleren...", + "followDevice": "Volg de aanwijzingen op uw KeepKey om de gevraagde woorden in te voeren.", + "verified": "Herstelzin gecontroleerd!", + "verifiedDetail": "Uw back-up is correct. Bewaar hem veilig en deel hem nooit met iemand.", + "continue": "Doorgaan", + "failed": "Controle mislukt", + "failedDetail": "De ingevoerde woorden komen niet overeen. Probeer het opnieuw of controleer uw geschreven back-up.", + "tryAgain": "Opnieuw proberen" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Uw PIN is gehusseld", + "body": "Uw KeepKey toont een willekeurig cijferraster. Koppel de posities op het scherm aan de cijfers op het apparaat. De indeling verandert elke keer zodat meekijkers uw PIN niet kunnen stelen." + }, + "words": { + "title": "Uw woorden = uw wallet", + "body": "Schrijf uw 12/24 woorden op papier. Bewaar ze veilig. Typ ze nooit op een computer, website of telefoon. Iedereen met deze woorden beheert uw geld." + }, + "recovery": { + "title": "Gehusselde herstelinvoer", + "body": "Bij herstel husselt KeepKey het alfabet op het apparaatscherm. U voert woorden in op positie, nooit door de echte letters te typen. Keyloggers zien niets bruikbaars." + }, + "deviceScreen": { + "title": "Vertrouw op het apparaatscherm", + "body": "Bevestig altijd het adres en bedrag op uw KeepKey voordat u goedkeurt. Uw computer kan gecompromitteerd zijn; het apparaatscherm niet. Zeker bij grote transacties." + }, + "appConnections": { + "title": "App-verbindingen staan uit", + "body": "Apps van derden en dApps verbinden via de REST API. Die staat standaard uit voor uw bescherming. Schakel hem alleen in Instellingen in wanneer nodig." + }, + "hiddenWallets": { + "title": "Verborgen wallets (geavanceerd)", + "body": "Een passphrase maakt een aparte verborgen wallet uit dezelfde seed. Als dit aanstaat, MOET u die onthouden: een verkeerde passphrase opent een andere lege wallet, geen foutmelding." + } + }, + "actions": { + "getStarted": "Aan de slag", + "startUsing": "KeepKey gebruiken", + "skipIntro": "Intro overslaan", + "skipTips": "Tips overslaan" + }, + "stepCounter": "{{current}} van {{total}}", + "walletLabels": { + "visible": "zichtbaar", + "hidden": "verborgen" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pl/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/pl/setup.json index b8dd0fc4..f07977cb 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pl/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pl/setup.json @@ -8,7 +8,9 @@ "initChoose": "Wybierz metodę konfiguracji", "initProgress": "Konfigurowanie portfela", "initLabel": "Nazwij swoje urządzenie", - "complete": "Konfiguracja zakończona!" + "complete": "Konfiguracja zakończona!", + "verifySeed": "Zweryfikuj frazę odzyskiwania", + "securityTips": "Wskazówki bezpieczeństwa" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Twój KeepKey poprowadzi cię przez proces aktualizacji.", "bootloaderDetected": "Wykryto tryb bootloadera", "deviceReadyForUpdate": "Twój KeepKey jest w trybie bootloadera i gotowy do aktualizacji.", - "updateBootloaderTo": "Zaktualizuj bootloader do v{{version}}" + "updateBootloaderTo": "Zaktualizuj bootloader do v{{version}}", + "doNotHoldButton": "NIE trzymaj przycisku podczas ponownego podłączania. Po prostu podłącz urządzenie normalnie. Przytrzymanie przycisku ponownie uruchomi tryb bootloadera." }, "firmware": { "title": "Aktualizacja oprogramowania", @@ -107,13 +110,19 @@ "rebootTakingLong": "Ponowne połączenie trwa dłużej niż zwykle...", "rebootTakingLongSub": "Urządzenie może potrzebować chwili na restart.", "pleaseDisconnect": "Odłącz i ponownie podłącz swój KeepKey", - "disconnectMessage": "Twoje urządzenie wyświetla \u201EAktualizacja firmware zako\u0144czona\u201C. Odłącz kabel USB i podłącz go ponownie, aby kontynuować.", + "disconnectMessage": "Twoje urządzenie wyświetla „Aktualizacja firmware zakończona“. Odłącz kabel USB i podłącz go ponownie, aby kontynuować.", "stillWaitingDisconnect": "Nadal czekam — upewnij się, że odłączyłeś i ponownie podłączyłeś kabel USB.", "manualReconnectTitle": "Urządzenie nie łączy się ponownie?", "manualReconnectStep1": "1. Odłącz KeepKey", "manualReconnectStep2": "2. Poczekaj 5 sekund", "manualReconnectStep3": "3. Podłącz ponownie", - "manualReconnectNote": "Konfiguracja będzie kontynuowana automatycznie po wykryciu urządzenia." + "manualReconnectNote": "Konfiguracja będzie kontynuowana automatycznie po wykryciu urządzenia.", + "unsignedBootloaderWarning": "Instalujesz niepodpisane firmware (deweloperskie). Jeśli urządzenie wcześniej działało na podpisanym (oficjalnym) firmware, wszystkie dane zostaną usunięte. To granica bezpieczeństwa wymuszana sprzętowo; to samo dotyczy powrotu do podpisanego firmware.", + "unsignedBootloaderAcknowledge": "Rozumiem, że to może wymazać urządzenie, i mam kopię zapasową seeda", + "autoRebooting": "Urządzenie uruchamia się ponownie...", + "autoRebootingDetail": "KeepKey automatycznie uruchomi się ponownie z nowym firmware. Zwykle trwa to kilka sekund.", + "waitingForDevice": "Oczekiwanie na urządzenie...", + "wipeAndFlash": "Wyczyść i zainstaluj" }, "initChoose": { "title": "Skonfiguruj swój portfel", @@ -123,7 +132,12 @@ "createWallet": "Utwórz portfel", "recoverExistingWallet": "Odzyskaj istniejący portfel", "recoverDescription": "Wprowadź frazę odzyskiwania na urządzeniu", - "recoverWallet": "Odzyskaj portfel" + "recoverWallet": "Odzyskaj portfel", + "seedLength": "Długość seeda", + "words": "słów", + "entropyNote": "Dodatkowa długość seeda nie poprawia ogólnej entropii portfela.", + "learnMore": "Dowiedz się więcej", + "howManyWords": "Ile słów ma twój seed?" }, "initProgress": { "creatingWallet": "Tworzenie portfela...", @@ -133,7 +147,11 @@ "followPrompts": "Postępuj zgodnie z instrukcjami na ekranie KeepKey.", "lookAtDevice": "Spójrz na urządzenie KeepKey i postępuj zgodnie z instrukcjami na ekranie.", "failedToCreate": "Nie udało się utworzyć portfela", - "failedToRecover": "Nie udało się odzyskać portfela" + "failedToRecover": "Nie udało się odzyskać portfela", + "writeDownWarning": "Zapisz każde słowo!", + "writeDownDetail": "Fraza odzyskiwania jest wyświetlana na ekranie urządzenia. Zapisz każde słowo na papierze. To JEDYNA kopia zapasowa; NIE zobaczysz tych słów ponownie.", + "deviceLost": "Urządzenie odłączone. Podłącz je ponownie, aby kontynuować, albo wróć i spróbuj jeszcze raz.", + "goBack": "Wróć" }, "initLabel": { "walletCreated": "Portfel utworzony!", @@ -155,6 +173,65 @@ "stepOf": "Krok {{current}} z {{total}}", "settingUpWallet": "Konfigurowanie portfela...", "previous": "Poprzedni", - "next": "Dalej" + "next": "Dalej", + "securityTips": "Wskazówki bezpieczeństwa" + }, + "verifySeed": { + "title": "Zweryfikuj frazę odzyskiwania", + "descriptionEmulator": "Poprosimy o 3 losowe słowa z seeda, aby potwierdzić, że kopia zapasowa jest poprawna.", + "description": "Potwierdź, że poprawnie zapisałeś frazę odzyskiwania. Urządzenie poprosi o wpisanie kilku słów.", + "verifyNow": "Zweryfikuj teraz", + "skipForNow": "Pomiń; sprawdzę później", + "enterWords": "Wprowadź wymagane słowa", + "enterWordsDetail": "Wpisz poprawne słowo dla każdej pozycji z frazy odzyskiwania.", + "checkWords": "Sprawdź słowa", + "verifying": "Weryfikowanie...", + "checkingAnswers": "Sprawdzanie odpowiedzi...", + "followDevice": "Postępuj zgodnie z instrukcjami na KeepKey, aby wpisać wymagane słowa.", + "verified": "Fraza odzyskiwania zweryfikowana!", + "verifiedDetail": "Kopia zapasowa jest poprawna. Przechowuj ją bezpiecznie i nigdy nikomu jej nie udostępniaj.", + "continue": "Kontynuuj", + "failed": "Weryfikacja nie powiodła się", + "failedDetail": "Wpisane słowa nie pasują. Spróbuj ponownie albo sprawdź zapisaną kopię.", + "tryAgain": "Spróbuj ponownie" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Twój PIN jest pomieszany", + "body": "KeepKey pokazuje losową siatkę cyfr. Dopasuj pozycje na ekranie do cyfr na urządzeniu. Układ zmienia się za każdym razem, aby obserwatorzy nie mogli ukraść PIN-u." + }, + "words": { + "title": "Twoje słowa = twój portfel", + "body": "Zapisz swoje 12/24 słowa na papierze. Przechowuj je bezpiecznie. Nigdy nie wpisuj ich na komputerze, stronie internetowej ani telefonie. Kto ma te słowa, kontroluje twoje środki." + }, + "recovery": { + "title": "Pomieszane wprowadzanie odzyskiwania", + "body": "Podczas odzyskiwania KeepKey miesza alfabet na ekranie urządzenia. Wprowadzasz słowa według pozycji, nigdy wpisując prawdziwe litery. Keyloggery nie widzą nic przydatnego." + }, + "deviceScreen": { + "title": "Ufaj ekranowi urządzenia", + "body": "Zawsze potwierdzaj adres i kwotę na KeepKey przed zatwierdzeniem. Komputer może być przejęty; ekran urządzenia nie. Szczególnie przy dużych transakcjach." + }, + "appConnections": { + "title": "Połączenia aplikacji są wyłączone", + "body": "Aplikacje zewnętrzne i dAppy łączą się przez REST API. Domyślnie jest wyłączone dla twojej ochrony. Włączaj je w Ustawieniach tylko wtedy, gdy jest potrzebne." + }, + "hiddenWallets": { + "title": "Ukryte portfele (zaawansowane)", + "body": "Passphrase tworzy oddzielny ukryty portfel z tego samego seeda. Jeśli ją włączysz, MUSISZ ją pamiętać: błędna passphrase otwiera inny pusty portfel, a nie błąd." + } + }, + "actions": { + "getStarted": "Rozpocznij", + "startUsing": "Zacznij używać KeepKey", + "skipIntro": "Pomiń wprowadzenie", + "skipTips": "Pomiń wskazówki" + }, + "stepCounter": "{{current}} z {{total}}", + "walletLabels": { + "visible": "widoczny", + "hidden": "ukryty" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json index eb1668cf..b31def4c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json @@ -8,7 +8,9 @@ "initChoose": "Escolha seu método de configuração", "initProgress": "Configurando sua carteira", "initLabel": "Nomeie seu dispositivo", - "complete": "Configuração concluída!" + "complete": "Configuração concluída!", + "verifySeed": "Verificar sua frase de recuperação", + "securityTips": "Dicas de segurança" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "Seu KeepKey irá guiá-lo pelo processo de atualização.", "bootloaderDetected": "Modo Bootloader detectado", "deviceReadyForUpdate": "Seu KeepKey está no modo bootloader e pronto para atualização.", - "updateBootloaderTo": "Atualizar Bootloader para v{{version}}" + "updateBootloaderTo": "Atualizar Bootloader para v{{version}}", + "doNotHoldButton": "NÃO mantenha o botão pressionado ao reconectar. Apenas conecte normalmente. Manter o botão pressionado colocará o dispositivo de volta no modo bootloader." }, "firmware": { "title": "Atualização de Firmware", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Desconecte seu KeepKey", "manualReconnectStep2": "2. Aguarde 5 segundos", "manualReconnectStep3": "3. Conecte novamente", - "manualReconnectNote": "A configuração continuará automaticamente quando o dispositivo for detectado." + "manualReconnectNote": "A configuração continuará automaticamente quando o dispositivo for detectado.", + "unsignedBootloaderWarning": "Você está instalando firmware não assinado (de desenvolvedor). Se este dispositivo estava executando firmware assinado (oficial), todos os dados serão apagados. Este é um limite de segurança imposto pelo hardware; o mesmo vale ao voltar para firmware assinado.", + "unsignedBootloaderAcknowledge": "Entendo que isso pode apagar o dispositivo e tenho backup da minha seed", + "autoRebooting": "Seu dispositivo está reiniciando...", + "autoRebootingDetail": "Seu KeepKey reiniciará automaticamente com o novo firmware. Isso geralmente leva alguns segundos.", + "waitingForDevice": "Aguardando seu dispositivo...", + "wipeAndFlash": "Apagar e instalar" }, "initChoose": { "title": "Configure sua carteira", @@ -123,7 +132,12 @@ "createWallet": "Criar carteira", "recoverExistingWallet": "Recuperar carteira existente", "recoverDescription": "Insira sua frase semente de recuperação no dispositivo", - "recoverWallet": "Recuperar carteira" + "recoverWallet": "Recuperar carteira", + "seedLength": "Tamanho da seed", + "words": "palavras", + "entropyNote": "Adicionar palavras à seed não melhora a entropia geral da carteira.", + "learnMore": "Saiba mais", + "howManyWords": "Quantas palavras há na sua seed?" }, "initProgress": { "creatingWallet": "Criando carteira...", @@ -133,7 +147,11 @@ "followPrompts": "Siga as instruções na tela do seu KeepKey.", "lookAtDevice": "Olhe para o seu KeepKey e siga as instruções na tela.", "failedToCreate": "Falha ao criar a carteira", - "failedToRecover": "Falha ao recuperar a carteira" + "failedToRecover": "Falha ao recuperar a carteira", + "writeDownWarning": "Anote cada palavra!", + "writeDownDetail": "Sua frase de recuperação está aparecendo na tela do dispositivo. Escreva cada palavra em papel. Este é seu ÚNICO backup; você NÃO verá essas palavras novamente.", + "deviceLost": "Dispositivo desconectado. Conecte-o novamente para continuar, ou volte para tentar de novo.", + "goBack": "Voltar" }, "initLabel": { "walletCreated": "Carteira criada!", @@ -155,6 +173,65 @@ "stepOf": "Passo {{current}} de {{total}}", "settingUpWallet": "Configurando carteira...", "previous": "Anterior", - "next": "Próximo" + "next": "Próximo", + "securityTips": "Dicas de segurança" + }, + "verifySeed": { + "title": "Verificar sua frase de recuperação", + "descriptionEmulator": "Vamos pedir 3 palavras aleatórias da sua seed para confirmar que você tem um backup correto.", + "description": "Confirme que você anotou sua frase de recuperação corretamente. Seu dispositivo pedirá que você insira algumas palavras.", + "verifyNow": "Verificar agora", + "skipForNow": "Pular; verificarei depois", + "enterWords": "Digite as palavras solicitadas", + "enterWordsDetail": "Digite a palavra correta para cada posição da sua frase de recuperação.", + "checkWords": "Conferir palavras", + "verifying": "Verificando...", + "checkingAnswers": "Conferindo suas respostas...", + "followDevice": "Siga as instruções no KeepKey para inserir as palavras solicitadas.", + "verified": "Frase de recuperação verificada!", + "verifiedDetail": "Seu backup está correto. Guarde-o com segurança e nunca compartilhe com ninguém.", + "continue": "Continuar", + "failed": "Verificação falhou", + "failedDetail": "As palavras digitadas não correspondem. Tente novamente ou confira seu backup escrito.", + "tryAgain": "Tentar novamente" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Seu PIN é embaralhado", + "body": "Seu KeepKey mostra uma grade numérica aleatória. Combine as posições na tela com os números no dispositivo. O layout muda toda vez para que observadores não roubem seu PIN." + }, + "words": { + "title": "Suas palavras = sua carteira", + "body": "Escreva suas 12/24 palavras em papel. Guarde-as em um lugar seguro. Nunca as digite em computador, site ou telefone. Quem tiver essas palavras controla seus fundos." + }, + "recovery": { + "title": "Entrada de recuperação embaralhada", + "body": "Ao recuperar, o KeepKey embaralha o alfabeto na tela do dispositivo. Você insere palavras por posição, nunca digitando as letras reais. Keyloggers não veem nada útil." + }, + "deviceScreen": { + "title": "Confie na tela do dispositivo", + "body": "Sempre confirme o endereço e o valor no KeepKey antes de aprovar. Seu computador pode estar comprometido; a tela do dispositivo não. Especialmente em transações grandes." + }, + "appConnections": { + "title": "Conexões de apps estão desligadas", + "body": "Apps de terceiros e dApps se conectam pela API REST. Ela fica desativada por padrão para sua proteção. Ative em Configurações somente quando precisar." + }, + "hiddenWallets": { + "title": "Carteiras ocultas (avançado)", + "body": "A passphrase cria uma carteira oculta separada a partir da mesma seed. Se ativada, você DEVE lembrá-la: uma passphrase errada abre outra carteira vazia, não um erro." + } + }, + "actions": { + "getStarted": "Começar", + "startUsing": "Começar a usar KeepKey", + "skipIntro": "Pular introdução", + "skipTips": "Pular dicas" + }, + "stepCounter": "{{current}} de {{total}}", + "walletLabels": { + "visible": "visível", + "hidden": "oculta" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json index 2a84f09d..1fef8c40 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json @@ -8,7 +8,9 @@ "initChoose": "Выберите способ настройки", "initProgress": "Настройка кошелька", "initLabel": "Дайте имя устройству", - "complete": "Настройка завершена!" + "complete": "Настройка завершена!", + "verifySeed": "Проверить фразу восстановления", + "securityTips": "Советы по безопасности" }, "visibleSteps": { "bootloader": "Загрузчик", @@ -62,7 +64,8 @@ "deviceWillGuide": "Ваш KeepKey проведёт вас через процесс обновления.", "bootloaderDetected": "Обнаружен режим загрузчика", "deviceReadyForUpdate": "Ваш KeepKey находится в режиме загрузчика и готов к обновлению.", - "updateBootloaderTo": "Обновить загрузчик до v{{version}}" + "updateBootloaderTo": "Обновить загрузчик до v{{version}}", + "doNotHoldButton": "НЕ удерживайте кнопку при повторном подключении. Просто подключите устройство обычным способом. Если удерживать кнопку, устройство снова перейдет в режим bootloader." }, "firmware": { "title": "Обновление прошивки", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Отключите ваш KeepKey", "manualReconnectStep2": "2. Подождите 5 секунд", "manualReconnectStep3": "3. Подключите его обратно", - "manualReconnectNote": "Настройка продолжится автоматически, когда устройство будет обнаружено." + "manualReconnectNote": "Настройка продолжится автоматически, когда устройство будет обнаружено.", + "unsignedBootloaderWarning": "Вы устанавливаете неподписанную прошивку (для разработчиков). Если ранее на устройстве была подписанная (официальная) прошивка, все данные будут удалены. Это аппаратно принудительная граница безопасности; то же относится к возврату на подписанную прошивку.", + "unsignedBootloaderAcknowledge": "Я понимаю, что это может стереть устройство, и у меня есть резервная копия seed-фразы", + "autoRebooting": "Устройство перезагружается...", + "autoRebootingDetail": "KeepKey автоматически перезапустится с новой прошивкой. Обычно это занимает несколько секунд.", + "waitingForDevice": "Ожидание устройства...", + "wipeAndFlash": "Стереть и прошить" }, "initChoose": { "title": "Настройте кошелёк", @@ -123,7 +132,12 @@ "createWallet": "Создать кошелёк", "recoverExistingWallet": "Восстановить существующий кошелёк", "recoverDescription": "Введите фразу восстановления на устройстве", - "recoverWallet": "Восстановить кошелёк" + "recoverWallet": "Восстановить кошелёк", + "seedLength": "Длина seed-фразы", + "words": "слов", + "entropyNote": "Увеличение длины seed-фразы не повышает общую энтропию кошелька.", + "learnMore": "Подробнее", + "howManyWords": "Сколько слов в вашей seed-фразе?" }, "initProgress": { "creatingWallet": "Создание кошелька...", @@ -133,7 +147,11 @@ "followPrompts": "Следуйте инструкциям на экране KeepKey.", "lookAtDevice": "Посмотрите на экран KeepKey и следуйте инструкциям.", "failedToCreate": "Не удалось создать кошелёк", - "failedToRecover": "Не удалось восстановить кошелёк" + "failedToRecover": "Не удалось восстановить кошелёк", + "writeDownWarning": "Запишите каждое слово!", + "writeDownDetail": "Фраза восстановления отображается на экране устройства. Запишите каждое слово на бумаге. Это ЕДИНСТВЕННАЯ резервная копия; вы НЕ увидите эти слова снова.", + "deviceLost": "Устройство отключено. Подключите его снова, чтобы продолжить, или вернитесь назад и попробуйте еще раз.", + "goBack": "Назад" }, "initLabel": { "walletCreated": "Кошелёк создан!", @@ -155,6 +173,65 @@ "stepOf": "Шаг {{current}} из {{total}}", "settingUpWallet": "Настройка кошелька...", "previous": "Назад", - "next": "Далее" + "next": "Далее", + "securityTips": "Советы по безопасности" + }, + "verifySeed": { + "title": "Проверить фразу восстановления", + "descriptionEmulator": "Мы попросим 3 случайных слова из seed-фразы, чтобы подтвердить, что резервная копия верна.", + "description": "Подтвердите, что вы правильно записали фразу восстановления. Устройство попросит ввести некоторые слова.", + "verifyNow": "Проверить сейчас", + "skipForNow": "Пропустить; проверю позже", + "enterWords": "Введите запрошенные слова", + "enterWordsDetail": "Введите правильное слово для каждой позиции из фразы восстановления.", + "checkWords": "Проверить слова", + "verifying": "Проверка...", + "checkingAnswers": "Проверка ответов...", + "followDevice": "Следуйте подсказкам на KeepKey, чтобы ввести запрошенные слова.", + "verified": "Фраза восстановления проверена!", + "verifiedDetail": "Резервная копия верна. Храните ее безопасно и никому не передавайте.", + "continue": "Продолжить", + "failed": "Проверка не удалась", + "failedDetail": "Введенные слова не совпадают. Попробуйте еще раз или проверьте записанную резервную копию.", + "tryAgain": "Попробовать снова" + }, + "tutorial": { + "cards": { + "pin": { + "title": "Ваш PIN перемешан", + "body": "KeepKey показывает случайную сетку цифр. Сопоставляйте позиции на экране с цифрами на устройстве. Раскладка меняется каждый раз, чтобы наблюдатели не смогли украсть ваш PIN." + }, + "words": { + "title": "Ваши слова = ваш кошелек", + "body": "Запишите 12/24 слова на бумаге. Храните их в безопасном месте. Никогда не вводите их на компьютере, сайте или телефоне. Любой, у кого есть эти слова, контролирует ваши средства." + }, + "recovery": { + "title": "Перемешанный ввод восстановления", + "body": "При восстановлении KeepKey перемешивает алфавит на экране устройства. Вы вводите слова по позициям, не набирая настоящие буквы. Кейлоггеры не увидят ничего полезного." + }, + "deviceScreen": { + "title": "Доверяйте экрану устройства", + "body": "Всегда подтверждайте адрес и сумму на KeepKey перед одобрением. Компьютер может быть скомпрометирован; экран устройства - нет. Особенно для крупных транзакций." + }, + "appConnections": { + "title": "Подключения приложений выключены", + "body": "Сторонние приложения и dApp подключаются через REST API. По умолчанию он выключен для вашей защиты. Включайте его в Настройках только при необходимости." + }, + "hiddenWallets": { + "title": "Скрытые кошельки (расширенно)", + "body": "Passphrase создает отдельный скрытый кошелек из той же seed-фразы. Если она включена, вы ОБЯЗАНЫ ее помнить: неверная passphrase откроет другой пустой кошелек, а не ошибку." + } + }, + "actions": { + "getStarted": "Начать", + "startUsing": "Начать использовать KeepKey", + "skipIntro": "Пропустить вводную", + "skipTips": "Пропустить советы" + }, + "stepCounter": "{{current}} из {{total}}", + "walletLabels": { + "visible": "видимый", + "hidden": "скрытый" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/th/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/th/setup.json index 9a926d98..cf27d560 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/th/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/th/setup.json @@ -8,7 +8,9 @@ "initChoose": "เลือกวิธีการตั้งค่า", "initProgress": "กำลังตั้งค่ากระเป๋า", "initLabel": "ตั้งชื่ออุปกรณ์ของคุณ", - "complete": "การตั้งค่าเสร็จสมบูรณ์!" + "complete": "การตั้งค่าเสร็จสมบูรณ์!", + "verifySeed": "ตรวจสอบวลีการกู้คืน", + "securityTips": "เคล็ดลับความปลอดภัย" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "KeepKey ของคุณจะแนะนำคุณผ่านกระบวนการอัปเดต", "bootloaderDetected": "ตรวจพบโหมด bootloader", "deviceReadyForUpdate": "KeepKey ของคุณอยู่ในโหมด bootloader และพร้อมสำหรับการอัปเดต", - "updateBootloaderTo": "อัปเดต bootloader เป็น v{{version}}" + "updateBootloaderTo": "อัปเดต bootloader เป็น v{{version}}", + "doNotHoldButton": "อย่ากดปุ่มค้างไว้เมื่อเชื่อมต่อใหม่ เพียงเสียบอุปกรณ์ตามปกติ การกดปุ่มค้างไว้จะทำให้อุปกรณ์กลับเข้าสู่โหมด bootloader" }, "firmware": { "title": "อัปเดตเฟิร์มแวร์", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. ถอด KeepKey", "manualReconnectStep2": "2. รอ 5 วินาที", "manualReconnectStep3": "3. เสียบกลับเข้าไป", - "manualReconnectNote": "การตั้งค่าจะดำเนินการต่อโดยอัตโนมัติเมื่อตรวจพบอุปกรณ์" + "manualReconnectNote": "การตั้งค่าจะดำเนินการต่อโดยอัตโนมัติเมื่อตรวจพบอุปกรณ์", + "unsignedBootloaderWarning": "คุณกำลังติดตั้งเฟิร์มแวร์ที่ไม่ได้ลงนาม (สำหรับนักพัฒนา) หากอุปกรณ์นี้เคยใช้เฟิร์มแวร์ที่ลงนามแล้ว (ทางการ) ข้อมูลทั้งหมดจะถูกลบ นี่เป็นขอบเขตความปลอดภัยที่บังคับโดยฮาร์ดแวร์ และมีผลเช่นเดียวกันเมื่อกลับไปใช้เฟิร์มแวร์ที่ลงนามแล้ว", + "unsignedBootloaderAcknowledge": "ฉันเข้าใจว่าสิ่งนี้อาจลบอุปกรณ์ และฉันได้สำรอง seed แล้ว", + "autoRebooting": "อุปกรณ์ของคุณกำลังรีสตาร์ท...", + "autoRebootingDetail": "KeepKey จะรีสตาร์ทอัตโนมัติด้วยเฟิร์มแวร์ใหม่ โดยปกติใช้เวลาไม่กี่วินาที", + "waitingForDevice": "กำลังรออุปกรณ์ของคุณ...", + "wipeAndFlash": "ลบและติดตั้ง" }, "initChoose": { "title": "ตั้งค่ากระเป๋าของคุณ", @@ -123,7 +132,12 @@ "createWallet": "สร้างกระเป๋า", "recoverExistingWallet": "กู้คืนกระเป๋าที่มีอยู่", "recoverDescription": "ป้อนวลีกู้คืนของคุณผ่านอุปกรณ์", - "recoverWallet": "กู้คืนกระเป๋า" + "recoverWallet": "กู้คืนกระเป๋า", + "seedLength": "ความยาว seed", + "words": "คำ", + "entropyNote": "การเพิ่มความยาว seed ไม่ได้เพิ่ม entropy โดยรวมของกระเป๋า", + "learnMore": "เรียนรู้เพิ่มเติม", + "howManyWords": "seed ของคุณมีกี่คำ?" }, "initProgress": { "creatingWallet": "กำลังสร้างกระเป๋า...", @@ -133,7 +147,11 @@ "followPrompts": "ทำตามคำแนะนำบนหน้าจอ KeepKey", "lookAtDevice": "ดูอุปกรณ์ KeepKey และทำตามคำแนะนำบนหน้าจอ", "failedToCreate": "ไม่สามารถสร้างกระเป๋าได้", - "failedToRecover": "ไม่สามารถกู้คืนกระเป๋าได้" + "failedToRecover": "ไม่สามารถกู้คืนกระเป๋าได้", + "writeDownWarning": "จดทุกคำไว้!", + "writeDownDetail": "วลีการกู้คืนของคุณกำลังแสดงบนหน้าจออุปกรณ์ เขียนแต่ละคำลงบนกระดาษ นี่คือข้อมูลสำรองเพียงชุดเดียวของคุณ และคุณจะไม่เห็นคำเหล่านี้อีก", + "deviceLost": "อุปกรณ์ถูกตัดการเชื่อมต่อ เสียบกลับเข้าไปเพื่อดำเนินการต่อ หรือย้อนกลับเพื่อลองใหม่", + "goBack": "ย้อนกลับ" }, "initLabel": { "walletCreated": "สร้างกระเป๋าแล้ว!", @@ -155,6 +173,65 @@ "stepOf": "ขั้นตอนที่ {{current}} จาก {{total}}", "settingUpWallet": "กำลังตั้งค่ากระเป๋า...", "previous": "ก่อนหน้า", - "next": "ถัดไป" + "next": "ถัดไป", + "securityTips": "เคล็ดลับความปลอดภัย" + }, + "verifySeed": { + "title": "ตรวจสอบวลีการกู้คืน", + "descriptionEmulator": "เราจะถาม 3 คำแบบสุ่มจาก seed เพื่อยืนยันว่าคุณมีข้อมูลสำรองที่ถูกต้อง", + "description": "ยืนยันว่าคุณจดวลีการกู้คืนถูกต้อง อุปกรณ์จะขอให้คุณป้อนบางคำ", + "verifyNow": "ตรวจสอบตอนนี้", + "skipForNow": "ข้าม; จะตรวจสอบภายหลัง", + "enterWords": "ป้อนคำที่ร้องขอ", + "enterWordsDetail": "พิมพ์คำที่ถูกต้องสำหรับแต่ละตำแหน่งจากวลีการกู้คืนของคุณ", + "checkWords": "ตรวจสอบคำ", + "verifying": "กำลังตรวจสอบ...", + "checkingAnswers": "กำลังตรวจคำตอบ...", + "followDevice": "ทำตามคำแนะนำบน KeepKey เพื่อป้อนคำที่ร้องขอ", + "verified": "ตรวจสอบวลีการกู้คืนแล้ว!", + "verifiedDetail": "ข้อมูลสำรองของคุณถูกต้อง เก็บไว้อย่างปลอดภัยและอย่าแชร์กับใคร", + "continue": "ดำเนินการต่อ", + "failed": "การตรวจสอบล้มเหลว", + "failedDetail": "คำที่คุณป้อนไม่ตรงกัน โปรดลองอีกครั้งหรือตรวจสอบข้อมูลสำรองที่จดไว้", + "tryAgain": "ลองอีกครั้ง" + }, + "tutorial": { + "cards": { + "pin": { + "title": "PIN ของคุณถูกสลับตำแหน่ง", + "body": "KeepKey แสดงตารางตัวเลขแบบสุ่ม จับคู่ตำแหน่งบนหน้าจอกับตัวเลขบนอุปกรณ์ รูปแบบจะเปลี่ยนทุกครั้งเพื่อไม่ให้ผู้แอบมองขโมย PIN ของคุณได้" + }, + "words": { + "title": "คำของคุณ = กระเป๋าของคุณ", + "body": "เขียน 12/24 คำลงบนกระดาษและเก็บไว้ในที่ปลอดภัย อย่าพิมพ์ลงในคอมพิวเตอร์ เว็บไซต์ หรือโทรศัพท์ ใครก็ตามที่มีคำเหล่านี้จะควบคุมเงินของคุณได้" + }, + "recovery": { + "title": "การป้อนกู้คืนแบบสลับ", + "body": "เมื่อกู้คืน KeepKey จะสลับตัวอักษรบนหน้าจออุปกรณ์ คุณป้อนคำตามตำแหน่ง ไม่ใช่พิมพ์ตัวอักษรจริง keylogger จะไม่เห็นข้อมูลที่มีประโยชน์" + }, + "deviceScreen": { + "title": "เชื่อถือหน้าจออุปกรณ์", + "body": "ยืนยันที่อยู่และจำนวนเงินบน KeepKey เสมอก่อนอนุมัติ คอมพิวเตอร์อาจถูกบุกรุกได้ แต่หน้าจออุปกรณ์ไม่ได้ โดยเฉพาะสำหรับธุรกรรมขนาดใหญ่" + }, + "appConnections": { + "title": "การเชื่อมต่อแอปปิดอยู่", + "body": "แอปบุคคลที่สามและ dApp เชื่อมต่อผ่าน REST API ซึ่งปิดไว้ตามค่าเริ่มต้นเพื่อปกป้องคุณ เปิดใน Settings เฉพาะเมื่อจำเป็น" + }, + "hiddenWallets": { + "title": "กระเป๋าซ่อน (ขั้นสูง)", + "body": "Passphrase สร้างกระเป๋าซ่อนแยกจาก seed เดียวกัน หากเปิดใช้ คุณต้องจำให้ได้: passphrase ที่ผิดจะเปิดกระเป๋าว่างใบอื่น ไม่ใช่ข้อผิดพลาด" + } + }, + "actions": { + "getStarted": "เริ่มต้น", + "startUsing": "เริ่มใช้ KeepKey", + "skipIntro": "ข้ามบทนำ", + "skipTips": "ข้ามเคล็ดลับ" + }, + "stepCounter": "{{current}} / {{total}}", + "walletLabels": { + "visible": "มองเห็น", + "hidden": "ซ่อน" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/tr/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/tr/setup.json index 7ad7cab7..7d25e7ff 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/tr/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/tr/setup.json @@ -8,7 +8,9 @@ "initChoose": "Kurulum yöntemini seçin", "initProgress": "Cüzdan yapılandırılıyor", "initLabel": "Cihazınıza isim verin", - "complete": "Kurulum tamamlandı!" + "complete": "Kurulum tamamlandı!", + "verifySeed": "Kurtarma ifadenizi doğrulayın", + "securityTips": "Güvenlik ipuçları" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "KeepKey'iniz güncelleme sürecinde size rehberlik edecek.", "bootloaderDetected": "Bootloader modu algılandı", "deviceReadyForUpdate": "KeepKey'iniz bootloader modunda ve güncellemeye hazır.", - "updateBootloaderTo": "Bootloader'ı v{{version}} sürümüne güncelle" + "updateBootloaderTo": "Bootloader'ı v{{version}} sürümüne güncelle", + "doNotHoldButton": "Yeniden bağlarken düğmeyi BASILI TUTMAYIN. Sadece normal şekilde takın. Düğmeyi basılı tutmak cihazı tekrar bootloader moduna alır." }, "firmware": { "title": "Yazılım güncellemesi", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. KeepKey'i çıkarın", "manualReconnectStep2": "2. 5 saniye bekleyin", "manualReconnectStep3": "3. Yeniden takın", - "manualReconnectNote": "Cihaz algılandığında kurulum otomatik olarak devam edecektir." + "manualReconnectNote": "Cihaz algılandığında kurulum otomatik olarak devam edecektir.", + "unsignedBootloaderWarning": "İmzasız (geliştirici) firmware yüklüyorsunuz. Bu cihaz daha önce imzalı (resmi) firmware çalıştırıyorsa tüm veriler silinecektir. Bu, donanım tarafından zorlanan bir güvenlik sınırıdır; imzalı firmware’e geri dönerken de aynısı geçerlidir.", + "unsignedBootloaderAcknowledge": "Bunun cihazı silebileceğini anlıyorum ve seed yedeğim var", + "autoRebooting": "Cihazınız yeniden başlatılıyor...", + "autoRebootingDetail": "KeepKey yeni firmware ile otomatik olarak yeniden başlayacak. Bu genellikle birkaç saniye sürer.", + "waitingForDevice": "Cihazınız bekleniyor...", + "wipeAndFlash": "Sil ve yükle" }, "initChoose": { "title": "Cüzdanınızı kurun", @@ -123,7 +132,12 @@ "createWallet": "Cüzdan oluştur", "recoverExistingWallet": "Mevcut cüzdanı kurtar", "recoverDescription": "Kurtarma ifadenizi cihazda girin", - "recoverWallet": "Cüzdanı kurtar" + "recoverWallet": "Cüzdanı kurtar", + "seedLength": "Seed uzunluğu", + "words": "kelime", + "entropyNote": "Seed uzunluğunu artırmak cüzdanın genel entropisini iyileştirmez.", + "learnMore": "Daha fazla bilgi", + "howManyWords": "Seed’inizde kaç kelime var?" }, "initProgress": { "creatingWallet": "Cüzdan oluşturuluyor...", @@ -133,7 +147,11 @@ "followPrompts": "KeepKey ekranındaki yönergeleri izleyin.", "lookAtDevice": "KeepKey cihazına bakın ve ekrandaki yönergeleri izleyin.", "failedToCreate": "Cüzdan oluşturulamadı", - "failedToRecover": "Cüzdan kurtarılamadı" + "failedToRecover": "Cüzdan kurtarılamadı", + "writeDownWarning": "Her kelimeyi yazın!", + "writeDownDetail": "Kurtarma ifadeniz cihaz ekranında gösteriliyor. Her kelimeyi kağıda yazın. Bu TEK yedeğinizdir; bu kelimeleri tekrar GÖRMEYECEKSİNİZ.", + "deviceLost": "Cihaz bağlantısı kesildi. Devam etmek için tekrar takın veya yeniden denemek için geri dönün.", + "goBack": "Geri dön" }, "initLabel": { "walletCreated": "Cüzdan oluşturuldu!", @@ -155,6 +173,65 @@ "stepOf": "Adım {{current}} / {{total}}", "settingUpWallet": "Cüzdan kuruluyor...", "previous": "Önceki", - "next": "İleri" + "next": "İleri", + "securityTips": "Güvenlik ipuçları" + }, + "verifySeed": { + "title": "Kurtarma ifadenizi doğrulayın", + "descriptionEmulator": "Yedeğinizin doğru olduğunu onaylamak için seed’inizden rastgele 3 kelime isteyeceğiz.", + "description": "Kurtarma ifadenizi doğru yazdığınızı onaylayın. Cihazınız bazı kelimeleri girmenizi isteyecek.", + "verifyNow": "Şimdi doğrula", + "skipForNow": "Atla; sonra doğrulayacağım", + "enterWords": "İstenen kelimeleri girin", + "enterWordsDetail": "Kurtarma ifadenizdeki her konum için doğru kelimeyi yazın.", + "checkWords": "Kelimeleri kontrol et", + "verifying": "Doğrulanıyor...", + "checkingAnswers": "Yanıtlarınız kontrol ediliyor...", + "followDevice": "İstenen kelimeleri girmek için KeepKey üzerindeki talimatları izleyin.", + "verified": "Kurtarma ifadesi doğrulandı!", + "verifiedDetail": "Yedeğiniz doğru. Güvenli tutun ve kimseyle paylaşmayın.", + "continue": "Devam et", + "failed": "Doğrulama başarısız", + "failedDetail": "Girdiğiniz kelimeler eşleşmedi. Tekrar deneyin veya yazılı yedeğinizi kontrol edin.", + "tryAgain": "Tekrar dene" + }, + "tutorial": { + "cards": { + "pin": { + "title": "PIN’iniz karıştırılır", + "body": "KeepKey rastgele bir sayı ızgarası gösterir. Ekrandaki konumları cihazdaki sayılarla eşleştirin. Düzen her seferinde değişir, böylece izleyenler PIN’inizi çalamaz." + }, + "words": { + "title": "Kelimeleriniz = cüzdanınız", + "body": "12/24 kelimenizi kağıda yazın. Güvenli bir yerde saklayın. Bunları asla bilgisayara, web sitesine veya telefona yazmayın. Bu kelimelere sahip olan herkes fonlarınızı kontrol eder." + }, + "recovery": { + "title": "Karıştırılmış kurtarma girişi", + "body": "Kurtarma sırasında KeepKey cihaz ekranındaki alfabeyi karıştırır. Kelimeleri konuma göre girersiniz; gerçek harfleri yazmazsınız. Keylogger’lar işe yarar hiçbir şey görmez." + }, + "deviceScreen": { + "title": "Cihaz ekranına güvenin", + "body": "Onaylamadan önce adresi ve tutarı her zaman KeepKey üzerinde doğrulayın. Bilgisayarınız ele geçirilebilir; cihaz ekranınız ele geçirilemez. Özellikle büyük işlemlerde." + }, + "appConnections": { + "title": "Uygulama bağlantıları kapalı", + "body": "Üçüncü taraf uygulamalar ve dApp’ler REST API ile bağlanır. Korumanız için varsayılan olarak kapalıdır. Yalnızca gerektiğinde Ayarlar’dan açın." + }, + "hiddenWallets": { + "title": "Gizli cüzdanlar (gelişmiş)", + "body": "Passphrase aynı seed’den ayrı bir gizli cüzdan oluşturur. Etkinleştirirseniz MUTLAKA hatırlamalısınız: yanlış passphrase hata değil, farklı boş bir cüzdan açar." + } + }, + "actions": { + "getStarted": "Başla", + "startUsing": "KeepKey kullanmaya başla", + "skipIntro": "Girişi atla", + "skipTips": "İpuçlarını atla" + }, + "stepCounter": "{{current}} / {{total}}", + "walletLabels": { + "visible": "görünür", + "hidden": "gizli" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/vi/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/vi/setup.json index e7867ba8..c90afea8 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/vi/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/vi/setup.json @@ -8,7 +8,9 @@ "initChoose": "Chọn phương thức thiết lập", "initProgress": "Đang thiết lập ví", "initLabel": "Đặt tên cho thiết bị", - "complete": "Thiết lập hoàn tất!" + "complete": "Thiết lập hoàn tất!", + "verifySeed": "Xác minh cụm từ khôi phục", + "securityTips": "Mẹo bảo mật" }, "visibleSteps": { "bootloader": "Bootloader", @@ -62,7 +64,8 @@ "deviceWillGuide": "KeepKey của bạn sẽ hướng dẫn bạn qua quá trình cập nhật.", "bootloaderDetected": "Đã phát hiện chế độ bootloader", "deviceReadyForUpdate": "KeepKey của bạn đang ở chế độ bootloader và sẵn sàng cập nhật.", - "updateBootloaderTo": "Cập nhật bootloader lên v{{version}}" + "updateBootloaderTo": "Cập nhật bootloader lên v{{version}}", + "doNotHoldButton": "KHÔNG giữ nút khi kết nối lại. Chỉ cần cắm thiết bị bình thường. Giữ nút sẽ đưa thiết bị trở lại chế độ bootloader." }, "firmware": { "title": "Cập nhật phần mềm", @@ -113,7 +116,13 @@ "manualReconnectStep1": "1. Rút KeepKey ra", "manualReconnectStep2": "2. Đợi 5 giây", "manualReconnectStep3": "3. Cắm lại", - "manualReconnectNote": "Thiết lập sẽ tự động tiếp tục khi phát hiện thiết bị." + "manualReconnectNote": "Thiết lập sẽ tự động tiếp tục khi phát hiện thiết bị.", + "unsignedBootloaderWarning": "Bạn đang cài firmware chưa ký (dành cho nhà phát triển). Nếu thiết bị này trước đó chạy firmware đã ký (chính thức), toàn bộ dữ liệu sẽ bị xóa. Đây là ranh giới bảo mật do phần cứng áp đặt; điều tương tự áp dụng khi quay lại firmware đã ký.", + "unsignedBootloaderAcknowledge": "Tôi hiểu việc này có thể xóa thiết bị và tôi đã sao lưu seed", + "autoRebooting": "Thiết bị của bạn đang khởi động lại...", + "autoRebootingDetail": "KeepKey sẽ tự động khởi động lại với firmware mới. Việc này thường mất vài giây.", + "waitingForDevice": "Đang chờ thiết bị của bạn...", + "wipeAndFlash": "Xóa và cài đặt" }, "initChoose": { "title": "Thiết lập ví của bạn", @@ -123,7 +132,12 @@ "createWallet": "Tạo ví", "recoverExistingWallet": "Khôi phục ví hiện có", "recoverDescription": "Nhập cụm từ khôi phục trên thiết bị", - "recoverWallet": "Khôi phục ví" + "recoverWallet": "Khôi phục ví", + "seedLength": "Độ dài seed", + "words": "từ", + "entropyNote": "Tăng độ dài seed không cải thiện entropy tổng thể của ví.", + "learnMore": "Tìm hiểu thêm", + "howManyWords": "Seed của bạn có bao nhiêu từ?" }, "initProgress": { "creatingWallet": "Đang tạo ví...", @@ -133,7 +147,11 @@ "followPrompts": "Thực hiện theo hướng dẫn trên màn hình KeepKey.", "lookAtDevice": "Nhìn vào thiết bị KeepKey và thực hiện theo hướng dẫn trên màn hình.", "failedToCreate": "Không thể tạo ví", - "failedToRecover": "Không thể khôi phục ví" + "failedToRecover": "Không thể khôi phục ví", + "writeDownWarning": "Hãy ghi lại từng từ!", + "writeDownDetail": "Cụm từ khôi phục của bạn đang hiển thị trên màn hình thiết bị. Ghi từng từ ra giấy. Đây là bản sao lưu DUY NHẤT; bạn sẽ KHÔNG thấy lại các từ này.", + "deviceLost": "Thiết bị đã ngắt kết nối. Cắm lại để tiếp tục, hoặc quay lại để thử lại.", + "goBack": "Quay lại" }, "initLabel": { "walletCreated": "Ví đã được tạo!", @@ -155,6 +173,65 @@ "stepOf": "Bước {{current}} trên {{total}}", "settingUpWallet": "Đang thiết lập ví...", "previous": "Trước", - "next": "Tiếp" + "next": "Tiếp", + "securityTips": "Mẹo bảo mật" + }, + "verifySeed": { + "title": "Xác minh cụm từ khôi phục", + "descriptionEmulator": "Chúng tôi sẽ hỏi 3 từ ngẫu nhiên trong seed để xác nhận bạn có bản sao lưu đúng.", + "description": "Xác nhận rằng bạn đã ghi đúng cụm từ khôi phục. Thiết bị sẽ yêu cầu bạn nhập một số từ.", + "verifyNow": "Xác minh ngay", + "skipForNow": "Bỏ qua; tôi sẽ xác minh sau", + "enterWords": "Nhập các từ được yêu cầu", + "enterWordsDetail": "Nhập đúng từ cho từng vị trí trong cụm từ khôi phục.", + "checkWords": "Kiểm tra từ", + "verifying": "Đang xác minh...", + "checkingAnswers": "Đang kiểm tra câu trả lời...", + "followDevice": "Làm theo hướng dẫn trên KeepKey để nhập các từ được yêu cầu.", + "verified": "Cụm từ khôi phục đã được xác minh!", + "verifiedDetail": "Bản sao lưu của bạn chính xác. Hãy giữ an toàn và không bao giờ chia sẻ với ai.", + "continue": "Tiếp tục", + "failed": "Xác minh thất bại", + "failedDetail": "Các từ bạn nhập không khớp. Vui lòng thử lại hoặc kiểm tra bản sao lưu đã ghi.", + "tryAgain": "Thử lại" + }, + "tutorial": { + "cards": { + "pin": { + "title": "PIN của bạn được xáo trộn", + "body": "KeepKey hiển thị một lưới số ngẫu nhiên. Hãy khớp vị trí trên màn hình với số trên thiết bị. Bố cục thay đổi mỗi lần để người nhìn trộm không thể lấy PIN của bạn." + }, + "words": { + "title": "Từ của bạn = ví của bạn", + "body": "Ghi 12/24 từ ra giấy. Cất chúng ở nơi an toàn. Không bao giờ nhập chúng vào máy tính, trang web hoặc điện thoại. Ai có các từ này sẽ kiểm soát tiền của bạn." + }, + "recovery": { + "title": "Nhập khôi phục được xáo trộn", + "body": "Khi khôi phục, KeepKey xáo trộn bảng chữ cái trên màn hình thiết bị. Bạn nhập từ theo vị trí, không bao giờ gõ chữ cái thật. Keylogger không thấy gì hữu ích." + }, + "deviceScreen": { + "title": "Tin vào màn hình thiết bị", + "body": "Luôn xác nhận địa chỉ và số tiền trên KeepKey trước khi phê duyệt. Máy tính có thể bị xâm nhập; màn hình thiết bị thì không. Đặc biệt với giao dịch lớn." + }, + "appConnections": { + "title": "Kết nối ứng dụng đang tắt", + "body": "Ứng dụng bên thứ ba và dApp kết nối qua REST API. Tính năng này tắt mặc định để bảo vệ bạn. Chỉ bật trong Cài đặt khi cần." + }, + "hiddenWallets": { + "title": "Ví ẩn (nâng cao)", + "body": "Passphrase tạo một ví ẩn riêng từ cùng seed. Nếu bật, bạn PHẢI nhớ nó: passphrase sai sẽ mở một ví trống khác, không phải lỗi." + } + }, + "actions": { + "getStarted": "Bắt đầu", + "startUsing": "Bắt đầu dùng KeepKey", + "skipIntro": "Bỏ qua giới thiệu", + "skipTips": "Bỏ qua mẹo" + }, + "stepCounter": "{{current}} / {{total}}", + "walletLabels": { + "visible": "hiển thị", + "hidden": "ẩn" + } } } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json index c89cd4e7..b33f9ae3 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json @@ -8,7 +8,9 @@ "initChoose": "选择设置方式", "initProgress": "正在设置钱包", "initLabel": "为设备命名", - "complete": "设置完成!" + "complete": "设置完成!", + "verifySeed": "验证恢复短语", + "securityTips": "安全提示" }, "visibleSteps": { "bootloader": "引导程序", @@ -62,7 +64,8 @@ "deviceWillGuide": "您的 KeepKey 将引导您完成更新过程。", "bootloaderDetected": "已检测到引导加载程序模式", "deviceReadyForUpdate": "您的 KeepKey 处于引导加载程序模式,已准备好进行更新。", - "updateBootloaderTo": "将引导程序更新至 v{{version}}" + "updateBootloaderTo": "将引导程序更新至 v{{version}}", + "doNotHoldButton": "重新连接时不要按住按钮。只需正常插入设备。按住按钮会让设备重新进入 bootloader 模式。" }, "firmware": { "title": "固件更新", @@ -107,13 +110,19 @@ "rebootTakingLong": "重新连接时间超出预期...", "rebootTakingLongSub": "设备可能需要一些时间来重启。", "pleaseDisconnect": "请断开并重新连接您的 KeepKey", - "disconnectMessage": "您的设备显示\u201C固件更新完成\u201D。请拔出USB线缆并重新插入以继续。", + "disconnectMessage": "您的设备显示“固件更新完成”。请拔出USB线缆并重新插入以继续。", "stillWaitingDisconnect": "仍在等待 — 请确保拔出并重新插入USB线缆。", "manualReconnectTitle": "设备无法重新连接?", "manualReconnectStep1": "1. 拔出您的 KeepKey", "manualReconnectStep2": "2. 等待 5 秒", "manualReconnectStep3": "3. 重新插入", - "manualReconnectNote": "检测到设备后,设置将自动继续。" + "manualReconnectNote": "检测到设备后,设置将自动继续。", + "unsignedBootloaderWarning": "你正在刷入未签名的开发者固件。如果此设备之前运行的是已签名的官方固件,所有数据都会被清除。这是硬件强制的安全边界;切回已签名固件时也同样适用。", + "unsignedBootloaderAcknowledge": "我理解这可能会清除设备,并且我已经备份 seed", + "autoRebooting": "设备正在重启...", + "autoRebootingDetail": "KeepKey 将使用新固件自动重启。通常只需几秒钟。", + "waitingForDevice": "正在等待设备...", + "wipeAndFlash": "清除并刷入" }, "initChoose": { "title": "设置您的钱包", @@ -123,7 +132,12 @@ "createWallet": "创建钱包", "recoverExistingWallet": "恢复现有钱包", "recoverDescription": "在设备上输入您的恢复助记词", - "recoverWallet": "恢复钱包" + "recoverWallet": "恢复钱包", + "seedLength": "Seed 长度", + "words": "个词", + "entropyNote": "增加 seed 长度不会提升钱包的整体熵。", + "learnMore": "了解更多", + "howManyWords": "你的 seed 有多少个词?" }, "initProgress": { "creatingWallet": "正在创建钱包...", @@ -133,7 +147,11 @@ "followPrompts": "请按照 KeepKey 设备屏幕上的提示操作。", "lookAtDevice": "请查看 KeepKey 设备并按照屏幕上的说明操作。", "failedToCreate": "创建钱包失败", - "failedToRecover": "恢复钱包失败" + "failedToRecover": "恢复钱包失败", + "writeDownWarning": "写下每一个词!", + "writeDownDetail": "恢复短语正在设备屏幕上显示。请把每个词写在纸上。这是你唯一的备份;这些词不会再次显示。", + "deviceLost": "设备已断开。请重新插入以继续,或返回后重试。", + "goBack": "返回" }, "initLabel": { "walletCreated": "钱包已创建!", @@ -155,6 +173,65 @@ "stepOf": "第 {{current}} 步,共 {{total}} 步", "settingUpWallet": "正在设置钱包...", "previous": "上一步", - "next": "下一步" + "next": "下一步", + "securityTips": "安全提示" + }, + "verifySeed": { + "title": "验证恢复短语", + "descriptionEmulator": "我们会从 seed 中随机询问 3 个词,以确认你的备份正确。", + "description": "确认你已正确写下恢复短语。设备会要求你输入其中几个词。", + "verifyNow": "立即验证", + "skipForNow": "跳过;稍后验证", + "enterWords": "输入要求的词", + "enterWordsDetail": "为恢复短语中的每个位置输入正确的词。", + "checkWords": "检查词语", + "verifying": "正在验证...", + "checkingAnswers": "正在检查答案...", + "followDevice": "按照 KeepKey 上的提示输入要求的词。", + "verified": "恢复短语已验证!", + "verifiedDetail": "你的备份正确。请安全保管,绝不要分享给任何人。", + "continue": "继续", + "failed": "验证失败", + "failedDetail": "你输入的词不匹配。请重试或检查你写下的备份。", + "tryAgain": "重试" + }, + "tutorial": { + "cards": { + "pin": { + "title": "PIN 会被打乱", + "body": "KeepKey 会显示随机数字网格。请将电脑屏幕上的位置与设备上的数字对应。布局每次都会改变,防止旁观者偷走你的 PIN。" + }, + "words": { + "title": "你的词 = 你的钱包", + "body": "把 12/24 个词写在纸上并安全保存。不要在电脑、网站或手机中输入它们。任何拥有这些词的人都能控制你的资金。" + }, + "recovery": { + "title": "打乱的恢复输入", + "body": "恢复时,KeepKey 会在设备屏幕上打乱字母表。你按位置输入单词,而不是输入真实字母。键盘记录器看不到有用信息。" + }, + "deviceScreen": { + "title": "信任设备屏幕", + "body": "批准前始终在 KeepKey 上确认地址和金额。电脑可能被攻破;设备屏幕不能被篡改。大额交易尤其如此。" + }, + "appConnections": { + "title": "应用连接已关闭", + "body": "第三方应用和 dApp 通过 REST API 连接。为保护你,默认关闭。只有在需要时才在设置中启用。" + }, + "hiddenWallets": { + "title": "隐藏钱包(高级)", + "body": "Passphrase 会从同一个 seed 创建独立的隐藏钱包。启用后必须记住它:错误的 passphrase 会打开另一个空钱包,而不是报错。" + } + }, + "actions": { + "getStarted": "开始", + "startUsing": "开始使用 KeepKey", + "skipIntro": "跳过介绍", + "skipTips": "跳过提示" + }, + "stepCounter": "{{current}} / {{total}}", + "walletLabels": { + "visible": "可见", + "hidden": "隐藏" + } } } diff --git a/projects/keepkey-vault/src/mainview/index.html b/projects/keepkey-vault/src/mainview/index.html index c8939972..da1e29b2 100644 --- a/projects/keepkey-vault/src/mainview/index.html +++ b/projects/keepkey-vault/src/mainview/index.html @@ -4,6 +4,9 @@ KeepKey Vault + + +
diff --git a/projects/keepkey-vault/src/mainview/layout.ts b/projects/keepkey-vault/src/mainview/layout.ts new file mode 100644 index 00000000..0334f18c --- /dev/null +++ b/projects/keepkey-vault/src/mainview/layout.ts @@ -0,0 +1,7 @@ +export const NAV_HEIGHT = "50px" +export const NAV_CONTENT_OFFSET = "54px" +export const NAV_CONTENT_OFFSET_WITH_BANNER = "104px" + +export const SPLASH_STATUS_BOTTOM = "30px" +export const SPLASH_STATUS_RESERVED = "96px" +export const SPLASH_STAGE_Y_PADDING = "clamp(20px, 4vh, 56px)" diff --git a/projects/keepkey-vault/src/mainview/lib/platform.ts b/projects/keepkey-vault/src/mainview/lib/platform.ts index 1f1a0d4c..6b0b8fe3 100644 --- a/projects/keepkey-vault/src/mainview/lib/platform.ts +++ b/projects/keepkey-vault/src/mainview/lib/platform.ts @@ -1,2 +1,3 @@ export const IS_WINDOWS = navigator.platform?.startsWith('Win') ?? false export const IS_MAC = navigator.platform?.startsWith('Mac') ?? false +export const IS_LINUX = !IS_WINDOWS && !IS_MAC diff --git a/projects/keepkey-vault/src/mainview/lib/rpc.ts b/projects/keepkey-vault/src/mainview/lib/rpc.ts index 01299538..86684aae 100644 --- a/projects/keepkey-vault/src/mainview/lib/rpc.ts +++ b/projects/keepkey-vault/src/mainview/lib/rpc.ts @@ -207,6 +207,19 @@ export function rpcFire(method: string, params?: any): void { sendPacket({ type: 'request', id: ++nextRequestId, method, params }) } +/** + * Re-emit a message locally to all subscribers of `messageName`. Used when one + * UI component receives a Bun message and needs to forward it to a sibling + * component that has just mounted (e.g. SwapRpcMount opens a dialog and needs + * the dialog's listener — registered after mount — to also see the original + * payload). + */ +export function dispatchLocalRpcMessage(messageName: string, payload: any): void { + const listeners = messageListeners.get(messageName) + if (!listeners) return + for (const listener of listeners) listener(payload) +} + /** * Listen for messages from the Bun main process. */ diff --git a/projects/keepkey-vault/src/mainview/lib/swapper-animations.ts b/projects/keepkey-vault/src/mainview/lib/swapper-animations.ts new file mode 100644 index 00000000..84ae511d --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/swapper-animations.ts @@ -0,0 +1,19 @@ +// Resolves a swapper/integration string to the GIF that plays in the +// route-map center node. Pre-signing (Confirm Quote) is "thinking" — the +// calculating gif — because at this point we're previewing a route, not +// moving anything yet. Post-signing (Submitted) uses shifting.gif from +// the SwapDialog directly. Per-provider art can branch here later. + +import calculatingGif from "../assets/swap/calculating.gif" + +function normalize(raw: string | undefined | null): string { + return (raw || "").toLowerCase().replace(/[\s_.-]/g, "") +} + +export function getSwapperAnimation( + swapper?: string | null, + integration?: string | null, +): string { + const _key = normalize(swapper) || normalize(integration) + return calculatingGif +} diff --git a/projects/keepkey-vault/src/mainview/lib/trackers.ts b/projects/keepkey-vault/src/mainview/lib/trackers.ts new file mode 100644 index 00000000..312e3a65 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/trackers.ts @@ -0,0 +1,112 @@ +// Provider-aware swap tracker URLs. +// +// Single source of truth for "given a swapper name + source tx hash, where +// can the user watch the swap settle?" +// +// THORChain / Maya / LI.FI can be looked up by the source-chain inbound hash. +// Aggregator routes that complete atomically (0x, Uniswap, 1inch, …) have no +// second leg — the source-chain Explorer link covers them, no provider tracker +// is shown. +// Relay needs its bytes32 request id, which vault now persists at trackSwap +// time (extracted from the deposit calldata) or backfills via api.relay.link. +// CoW / Across still return null (with a one-time console warn) until their +// equivalent IDs are plumbed through. + +export type ProviderTracker = { + url: string + label: string + iconUrl?: string +} + +export type ProviderTrackerOpts = { + /** Relay's bytes32 request id, when available. Required for the relay + * branch — falls through to null when missing so the lazy-backfill path + * in swap-tracker has time to populate it without flashing a dead link. */ + relayRequestId?: string +} + +const ICON = { + thor: 'https://pioneers.dev/coins/thorchain.png', + maya: 'https://pioneers.dev/coins/mayachain.png', + // Inline orange "R" badge — keeps the tracker button branded without + // depending on Relay's CDN (which is bot-protected and 429s for this client). + relay: + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + 'R', + ), +} + +const ATOMIC_PROVIDERS = new Set([ + '0x', 'zeroex', + 'uniswap', 'univ2', 'univ3', 'univ4', + '1inch', 'oneinch', + 'curve', + 'balancer', + 'sushiswap', 'sushi', +]) + +// Providers that still need a provider-side ID we don't surface yet. +const ID_BLOCKED_PROVIDERS = new Set([ + 'cow', 'cowswap', + 'across', +]) + +const RELAY_KEYS = new Set(['relay', 'relaylink', 'relayexchange']) + +function normalize(swapper: string | undefined | null): string { + return (swapper || '').toLowerCase().replace(/[\s_.-]/g, '') +} + +const _warned = new Set() +function warnOnce(key: string, msg: string): void { + if (_warned.has(key)) return + _warned.add(key) + try { console.warn('[trackers]', msg) } catch {} +} + +/** + * Return a provider-specific tracker link for a swap, or null when the + * source-chain Explorer is sufficient (atomic providers) or when the + * underlying protocol can't be resolved / needs an ID we don't surface yet. + * + * Vault renders the source Explorer link unconditionally, so null = "no + * second leg to track." + */ +export function providerTrackerUrl( + swapper: string | undefined | null, + txid: string | undefined | null, + opts?: ProviderTrackerOpts, +): ProviderTracker | null { + if (!txid) return null + const s = normalize(swapper) + if (!s) return null + + if (s.includes('thor')) { + const hash = txid.replace(/^0x/i, '').toUpperCase() + return { url: `https://track.thorchain.org/${hash}`, label: 'THORChain Track', iconUrl: ICON.thor } + } + if (s.includes('maya')) { + const hash = txid.replace(/^0x/i, '').toLowerCase() + return { url: `https://www.mayascan.org/tx/${hash}`, label: 'Maya Track', iconUrl: ICON.maya } + } + if (s === 'lifi') { + return { url: `https://scan.li.fi/tx/${txid}`, label: 'LI.FI Track' } + } + + if (RELAY_KEYS.has(s)) { + const id = opts?.relayRequestId + if (!id) return null // backfill in flight; UI re-renders when it lands + return { url: `https://relay.link/transaction/${id}`, label: 'Relay Track', iconUrl: ICON.relay } + } + + if (ATOMIC_PROVIDERS.has(s)) return null + + if (ID_BLOCKED_PROVIDERS.has(s)) { + warnOnce(s, `${swapper}: tracker URL needs a provider-side ID Pioneer doesn't surface yet — using source Explorer only.`) + return null + } + + warnOnce(s, `Unknown swapper "${swapper}" — no provider tracker registered.`) + return null +} diff --git a/projects/keepkey-vault/src/mainview/lib/z-index.ts b/projects/keepkey-vault/src/mainview/lib/z-index.ts index 99d8fd1b..f33e0644 100644 --- a/projects/keepkey-vault/src/mainview/lib/z-index.ts +++ b/projects/keepkey-vault/src/mainview/lib/z-index.ts @@ -4,5 +4,8 @@ export const Z = { drawerBackdrop: 1100, drawerPanel: 1200, dialog: 1500, + /** Sits above dialog (asset picker opened from SwapDialog) but below overlay + * so PIN/passphrase prompts always trump asset selection. */ + assetPicker: 1700, overlay: 2000, } as const diff --git a/projects/keepkey-vault/src/mainview/main.tsx b/projects/keepkey-vault/src/mainview/main.tsx index 47823ab6..8954b86c 100644 --- a/projects/keepkey-vault/src/mainview/main.tsx +++ b/projects/keepkey-vault/src/mainview/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client" import { ChakraProvider } from "@chakra-ui/react" import { system } from "./theme" import "./index.css" +import "./styles/tokens.css" import "./i18n" import splashBg from "./assets/splash-bg.png" import App from "./App" diff --git a/projects/keepkey-vault/src/mainview/styles/tokens.css b/projects/keepkey-vault/src/mainview/styles/tokens.css new file mode 100644 index 00000000..9483aeee --- /dev/null +++ b/projects/keepkey-vault/src/mainview/styles/tokens.css @@ -0,0 +1,81 @@ +/* UI v3 design tokens — source of truth lifted from the design study. + Consumed by components in src/mainview/components/v3/. Existing Chakra + tokens (theme.ts) remain authoritative for everything not yet migrated. */ + +:root { + /* Surfaces — graphite with a hint of warmth */ + --ink-0: #0b0b0e; + --ink-1: #101015; + --ink-2: #16161d; + --ink-3: #1c1c25; + --ink-4: #25252f; + --line: rgba(255, 255, 255, 0.06); + --line-2: rgba(255, 255, 255, 0.10); + + /* Text */ + --text-0: #f5f4ef; + --text-1: #c8c7be; + --text-2: #8a8a82; + --text-3: #56564f; + + /* Accents — used sparingly */ + --gold: #e9c46a; + --gold-2: #f2d27e; + --teal: #8be3c4; + --teal-2: #a8efd2; + --rose: #e08c7b; + --violet: #9f8ce0; + + /* Token brand swatches */ + --eth: #6c7be8; + --btc: #f0a85c; + --usdt: #4eb591; + --usdc: #4f7fc8; + --trx: #d4543c; + --sahara: #d4c75c; + --eeth: #8b7ae0; + + /* Radii */ + --r-sm: 8px; + --r-md: 14px; + --r-lg: 22px; + --r-xl: 28px; + + /* Shadows */ + --shadow-1: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-2: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px -8px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 0 1px rgba(139, 227, 196, 0.25), 0 0 40px -8px rgba(139, 227, 196, 0.35); + + /* Type stacks */ + --font-sans: 'Space Grotesk', -apple-system, system-ui, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, monospace; + --font-serif: 'Instrument Serif', serif; +} + +/* Class-scoped helpers — do NOT bind to body so existing Chakra screens are + untouched. v3-rooted subtrees opt in via .v3-root or class utilities. */ +.v3-root { + font-family: var(--font-sans); + letter-spacing: -0.005em; + background: var(--ink-0); + color: var(--text-0); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; +} + +.v3-mono { font-family: var(--font-mono); font-feature-settings: "tnum"; } +.v3-serif { font-family: var(--font-serif); font-style: italic; } + +/* Page enter transition (mirrors study) */ +@keyframes v3-fadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.v3-page-enter { animation: v3-fadeUp 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; } + +@keyframes v3-pulseGlow { + 0%, 100% { filter: drop-shadow(0 0 4px rgba(233, 196, 106, 0.4)); } + 50% { filter: drop-shadow(0 0 12px rgba(233, 196, 106, 0.8)); } +} + +@keyframes v3-spin { to { transform: rotate(360deg); } } diff --git a/projects/keepkey-vault/src/mainview/theme.ts b/projects/keepkey-vault/src/mainview/theme.ts index 50be40c3..d4741e71 100644 --- a/projects/keepkey-vault/src/mainview/theme.ts +++ b/projects/keepkey-vault/src/mainview/theme.ts @@ -1,48 +1,81 @@ import { createSystem, defaultConfig, defineConfig } from "@chakra-ui/react"; +/* The existing kk.* token names stay so we don't have to rewrite ~50 components + in one motion. Their *values* are bridged to the v3 study palette so the app + re-tints in place: graphite surfaces, cream text, muted gold + mint accents + instead of pure black + #FFD700. Pure CSS variable consumption (via inline + var(--ink-0) etc.) lives in src/mainview/styles/tokens.css; this file is the + bridge for screens still rendered through Chakra. */ + +const FONT_SANS = "'Space Grotesk', -apple-system, system-ui, sans-serif" +const FONT_MONO = "'Geist Mono', ui-monospace, monospace" +const FONT_SERIF = "'Instrument Serif', serif" + const config = defineConfig({ + globalCss: { + "html, body": { + fontFamily: FONT_SANS, + letterSpacing: "-0.005em", + fontFeatureSettings: '"ss01", "cv11"', + }, + }, theme: { tokens: { + fonts: { + heading: { value: FONT_SANS }, + body: { value: FONT_SANS }, + mono: { value: FONT_MONO }, + serif: { value: FONT_SERIF }, + }, colors: { + /* Gold ramp — desaturated study palette. The 500 slot stays the + "primary" gold and matches kk.gold. */ gold: { - 50: { value: "#FFF9E0" }, - 100: { value: "#FFECB3" }, - 200: { value: "#FFE082" }, - 300: { value: "#FFD54F" }, - 400: { value: "#FFCA28" }, - 500: { value: "#FFD700" }, - 600: { value: "#FFC107" }, - 700: { value: "#FFB300" }, - 800: { value: "#FFA000" }, - 900: { value: "#FF8F00" }, + 50: { value: "#FBF3D9" }, + 100: { value: "#F8EBC0" }, + 200: { value: "#F4DFA2" }, + 300: { value: "#F0D484" }, + 400: { value: "#ECCB76" }, + 500: { value: "#E9C46A" }, // --gold + 600: { value: "#D9B25A" }, + 700: { value: "#BF9A4A" }, + 800: { value: "#A0813C" }, + 900: { value: "#7A622D" }, }, kk: { - bg: { value: "#000000" }, - cardBg: { value: "#111111" }, - cardBgHover: { value: "#1A1A1A" }, - border: { value: "#222222" }, - borderAlt: { value: "#3A4A5C" }, - gold: { value: "#FFD700" }, - goldHover: { value: "#FFE135" }, - highlight: { value: "#E94560" }, - textPrimary: { value: "#FFFFFF" }, - textSecondary: { value: "#A0A0A0" }, - textMuted: { value: "#666666" }, - success: { value: "#00C853" }, - warning: { value: "#FFB300" }, - error: { value: "#FF1744" }, + /* Surfaces */ + bg: { value: "#0b0b0e" }, // --ink-0 + cardBg: { value: "#101015" }, // --ink-1 + cardBgHover: { value: "#16161d" }, // --ink-2 + border: { value: "#1c1c25" }, // --ink-3 (used as visible line) + borderAlt: { value: "#25252f" }, // --ink-4 + + /* Brand */ + gold: { value: "#e9c46a" }, // --gold + goldHover: { value: "#f2d27e" }, // --gold-2 + + /* Status accents — softened, no alarm-bell red */ + highlight: { value: "#e08c7b" }, // --rose + success: { value: "#8be3c4" }, // --teal + warning: { value: "#e9c46a" }, // --gold (same as primary, calmer than orange) + error: { value: "#e08c7b" }, // --rose + + /* Text */ + textPrimary: { value: "#f5f4ef" }, // --text-0 + textSecondary: { value: "#c8c7be" }, // --text-1 + textMuted: { value: "#8a8a82" }, // --text-2 }, }, }, semanticTokens: { colors: { - "bg": { value: "#000000" }, - "bg.subtle": { value: "#111111" }, - "bg.muted": { value: "#1A1A1A" }, - "fg": { value: "#FFFFFF" }, - "fg.muted": { value: "#A0A0A0" }, - "border": { value: "#222222" }, - "border.emphasized": { value: "#3A4A5C" }, + bg: { value: "#0b0b0e" }, + "bg.subtle": { value: "#101015" }, + "bg.muted": { value: "#16161d" }, + fg: { value: "#f5f4ef" }, + "fg.muted": { value: "#c8c7be" }, + border: { value: "#1c1c25" }, + "border.emphasized": { value: "#25252f" }, }, }, }, diff --git a/projects/keepkey-vault/src/shared/chains.ts b/projects/keepkey-vault/src/shared/chains.ts index b4504ec2..58591d2a 100644 --- a/projects/keepkey-vault/src/shared/chains.ts +++ b/projects/keepkey-vault/src/shared/chains.ts @@ -50,7 +50,12 @@ const CONFIGS: ChainConfig[] = [ id: 'bitcoin', chain: Chain.Bitcoin, coin: 'Bitcoin', symbol: 'BTC', chainFamily: 'utxo', color: '#F7931A', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', - defaultPath: [0x8000002C, 0x80000000, 0x80000000, 0, 0], scriptType: 'p2pkh', + // Native SegWit (BIP84) — matches btcAccountManager's default `p2wpkh` + // selection. Was Legacy (m/44'/0'/0') which silently sent BTC swap deliveries + // to a 1... address users didn't normally watch when btcAccountManager hadn't + // been initialized (cold start before BTC dashboard). Native SegWit is the + // de-facto modern default since 2017. + defaultPath: [0x80000054, 0x80000000, 0x80000000, 0, 0], scriptType: 'p2wpkh', explorerTxUrl: 'https://blockchair.com/bitcoin/transaction/{{txid}}', explorerAddressUrl: 'https://blockchair.com/bitcoin/address/{{address}}', }, @@ -203,7 +208,8 @@ const CONFIGS: ChainConfig[] = [ chainFamily: 'utxo', color: '#ECB244', rpcMethod: 'btcGetAddress', signMethod: 'btcSignTx', defaultPath: [0x8000002C, 0x80000085, 0x80000000, 0, 0], scriptType: 'p2pkh', - hidden: true, // Shown only when zcashPrivacyEnabled feature flag is ON + explorerTxUrl: 'https://blockchair.com/zcash/transaction/{{txid}}', + explorerAddressUrl: 'https://blockchair.com/zcash/address/{{address}}', minFirmware: '7.15.0', }, { @@ -289,9 +295,13 @@ export function getExplorerTxUrl(chainId: string, txid: string): string | null { const chain = CHAINS.find(c => c.id === chainId) if (!chain?.explorerTxUrl) return null // EVM explorers expect 0x prefix; all others (Mintscan, Runescan, Blockchair, etc.) do not - const normalizedTxid = chain.chainFamily === 'evm' + let normalizedTxid = chain.chainFamily === 'evm' ? (txid.startsWith('0x') ? txid : '0x' + txid) : txid.replace(/^0x/i, '') + // Tronscan URL routing is case-sensitive (`/transaction/EEB4...` 404s, `/eeb4...` works) + // even though TRON txids are hex. THORChain emits txids uppercased, so we'd otherwise + // hand the user a broken explorer link for any THORChain-routed swap landing on TRON. + if (chain.chainFamily === 'tron') normalizedTxid = normalizedTxid.toLowerCase() return chain.explorerTxUrl.replace('{{txid}}', normalizedTxid) } diff --git a/projects/keepkey-vault/src/shared/max-send.ts b/projects/keepkey-vault/src/shared/max-send.ts new file mode 100644 index 00000000..085f2a8d --- /dev/null +++ b/projects/keepkey-vault/src/shared/max-send.ts @@ -0,0 +1,71 @@ +export const TOKEN_MAX_DISPLAY_DECIMALS = 8 +const MAX_SAFE_DECIMALS = 255 + +export function normalizeDecimals(decimals: unknown): number | null { + const value = typeof decimals === 'number' + ? decimals + : typeof decimals === 'string' && decimals.trim() !== '' + ? Number(decimals) + : NaN + if (!Number.isInteger(value) || value < 0 || value > MAX_SAFE_DECIMALS) return null + return value +} + +export function decimalToBaseUnits(amount: string, decimals: unknown): bigint | null { + const precision = normalizeDecimals(decimals) + if (precision === null) return null + const normalized = String(amount || '').trim() + if (!/^(?:\d+\.?\d*|\.\d+)$/.test(normalized)) return null + + const [whole = '0', fraction = ''] = normalized.split('.') + const scale = 10n ** BigInt(precision) + const fractionalUnits = (fraction || '').slice(0, precision).padEnd(precision, '0') + return BigInt(whole) * scale + BigInt(fractionalUnits || '0') +} + +export function decimalToBaseUnitsStrict(amount: string, decimals: unknown): bigint { + const units = decimalToBaseUnits(amount, decimals) + if (units === null) throw new Error(`Invalid amount: ${amount}`) + return units +} + +export function baseUnitsToDecimalString(units: bigint, decimals: unknown): string { + const precision = normalizeDecimals(decimals) + if (precision === null || precision <= 0) return units.toString() + const scale = 10n ** BigInt(precision) + const whole = units / scale + const fraction = units % scale + if (fraction === 0n) return whole.toString() + const frac = fraction.toString().padStart(precision, '0').replace(/0+$/, '') + return `${whole.toString()}.${frac}` +} + +export function tokenMaxPrecisionReserveUnits(decimals: unknown): bigint { + const precision = normalizeDecimals(decimals) + if (precision === null) return 1n + if (precision === 0) return 0n + return 10n ** BigInt(Math.max(0, precision - TOKEN_MAX_DISPLAY_DECIMALS)) +} + +export function tokenMaxSpendableBaseUnits(balance: string, decimals: unknown): bigint | null { + const balanceUnits = decimalToBaseUnits(balance, decimals) + if (balanceUnits === null) return null + const reserveUnits = tokenMaxPrecisionReserveUnits(decimals) + return balanceUnits > reserveUnits ? balanceUnits - reserveUnits : 0n +} + +export function tokenMaxSpendableAmount(balance: string, decimals: unknown): string { + const spendableUnits = tokenMaxSpendableBaseUnits(balance, decimals) + if (spendableUnits === null) return balance + return baseUnitsToDecimalString(spendableUnits, decimals) +} + +export function nativeMaxSpendableAmount(balance: string, decimals: unknown, reserve: string | number): string { + const balanceUnits = decimalToBaseUnits(balance, decimals) + const reserveUnits = decimalToBaseUnits(String(reserve), decimals) + if (balanceUnits === null || reserveUnits === null) return balance + + const spendableUnits = balanceUnits - reserveUnits + if (spendableUnits <= 0n) return '0' + return baseUnitsToDecimalString(spendableUnits, decimals) +} diff --git a/projects/keepkey-vault/src/shared/relay-status.ts b/projects/keepkey-vault/src/shared/relay-status.ts new file mode 100644 index 00000000..321a3ca6 --- /dev/null +++ b/projects/keepkey-vault/src/shared/relay-status.ts @@ -0,0 +1,73 @@ +import type { SwapTrackingStatus } from './types' + +export interface RelayExecutionStatus { + status?: string | null + details?: string | null + inTxHashes?: string[] + txHashes?: string[] + time?: number | null + originChainId?: number | null + destinationChainId?: number | null +} + +const STATUS_RANK: Record = { + signing: 0, + pending: 1, + confirming: 2, + output_detected: 3, + output_confirming: 4, + output_confirmed: 5, + completed: 6, + failed: 6, + refunded: 6, +} + +export function mapRelayExecutionStatus(status: string | null | undefined): SwapTrackingStatus | null { + switch ((status || '').toLowerCase()) { + case 'success': + return 'completed' + case 'fallback': + case 'refund': + case 'refunded': + return 'refunded' + case 'failure': + case 'failed': + return 'failed' + case 'received': + case 'depositing': + case 'pending': + case 'delayed': + return 'confirming' + case 'submitted': + return 'output_confirming' + case 'waiting': + return 'pending' + default: + return null + } +} + +export function isTerminalSwapStatus(status: SwapTrackingStatus): boolean { + return status === 'completed' || status === 'failed' || status === 'refunded' +} + +export function shouldApplyRelayStatus(currentStatus: SwapTrackingStatus, relayStatus: SwapTrackingStatus, hasMetadataUpdate = false): boolean { + if (relayStatus === currentStatus) return hasMetadataUpdate + if (isTerminalSwapStatus(relayStatus)) return true + if (isTerminalSwapStatus(currentStatus)) return false + return STATUS_RANK[relayStatus] > STATUS_RANK[currentStatus] +} + +export function relayOutboundTxid(status: RelayExecutionStatus, fallbackTxid: string): string | undefined { + const explicit = status.txHashes?.find(Boolean) + if (explicit) return explicit + const sameChain = status.originChainId !== undefined + && status.originChainId !== null + && status.destinationChainId !== undefined + && status.destinationChainId !== null + && status.originChainId === status.destinationChainId + const inbound = status.inTxHashes?.find(Boolean) + return sameChain && mapRelayExecutionStatus(status.status) === 'completed' + ? (inbound || fallbackTxid) + : undefined +} diff --git a/projects/keepkey-vault/src/shared/relay-utils.ts b/projects/keepkey-vault/src/shared/relay-utils.ts new file mode 100644 index 00000000..5ba6e537 --- /dev/null +++ b/projects/keepkey-vault/src/shared/relay-utils.ts @@ -0,0 +1,36 @@ +// Helpers for extracting Relay protocol metadata out of deposit calldata. +// +// Relay's request id is a bytes32 that uniquely identifies a cross-chain order. +// On the inbound chain, the deposit contract receives the request id as a +// calldata argument — every Relay deposit selector we've observed places it as +// the LAST 32 bytes of an ABI-encoded payload. Persisting this id at trackSwap +// time (when we still hold the prebuilt calldata) lets us link to Relay's +// status page without a Pioneer round-trip and without parsing a quote shape +// we don't otherwise need. + +// Known Relay deposit selectors. We trust the trailing-32-bytes rule only for +// selectors we've seen, so unrelated EVM calls don't accidentally yield a +// "request id" that's actually somebody's address argument. +const KNOWN_RELAY_SELECTORS = new Set([ + '0x49290c1c', // depositNative(address recipient, bytes32 orderId) +]) + +/** + * Pull the Relay request id (bytes32) out of an inbound deposit calldata. + * + * Returns a 0x-prefixed lowercase 32-byte hex string when the calldata looks + * like a known Relay deposit, undefined otherwise. Pure / sync — does no I/O. + */ +export function extractRelayRequestId(calldata: string | undefined | null): string | undefined { + if (!calldata) return undefined + const data = calldata.startsWith('0x') ? calldata : `0x${calldata}` + // Selector(4) + at least one bytes32 arg(32) → minimum 4 + 32 = 36 bytes = 74 hex chars + "0x" + if (data.length < 2 + 8 + 64) return undefined + // Must be well-formed ABI-encoded: selector + N*32-byte words. + if ((data.length - 10) % 64 !== 0) return undefined + const selector = data.slice(0, 10).toLowerCase() + if (!KNOWN_RELAY_SELECTORS.has(selector)) return undefined + // Last 32 bytes (= 64 hex) are the request id. + const id = data.slice(-64).toLowerCase() + return `0x${id}` +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index c538ae83..eff04587 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, FatalEvent, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, PioneerServer, 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, RecentActivity, BuildStakingTxParams, StakingPosition, ZcashTransaction, EmulatorStatus, EmulatorWalletInfo, RegisteredDevice, WcSessionInfo } from './types' +import type { DeviceStateInfo, FirmwareProgress, FirmwareAnalysis, FatalEvent, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, PioneerServer, BtcGetAddressParams, EthGetAddressParams, EthSignTxParams, BtcSignTxParams, GetPublicKeysParams, UpdateInfo, UpdateStatus, TokenVisibilityStatus, PairingRequestInfo, PairedAppInfo, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, ReportMeta, ReportData, SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult, SwapHealth, PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryFilter, SwapHistoryStats, SwapUiState, SwapUiCommand, RecentActivity, BuildStakingTxParams, StakingPosition, ZcashTransaction, EmulatorStatus, EmulatorWalletInfo, RegisteredDevice, WcSessionInfo } from './types' /** * RPC Schema for Bun ↔ WebView communication. @@ -39,7 +39,15 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { // ── Wallet operations (hdwallet pass-through) ───────────────── getFeatures: { params: void; response: any } ping: { params: { msg?: string }; response: any } + // Open a URL in the user's default browser (escapes the WebView). + // The system WebView blocks target=_blank, so explorer/docs links + // route through here instead. Bun shells out to the OS-native opener. + openExternal: { params: { url: string }; response: { ok: true } } wipeDevice: { params: void; response: any } + // Sends a Cancel message to the device — aborts whatever confirm/PIN/ + // passphrase prompt is on screen and frees the transport lock so the + // user can back out of an in-flight signing flow without unplugging. + cancelDeviceSigning: { params: void; response: { ok: boolean } } // Types defined in types.ts: GetPublicKeysParams, BtcGetAddressParams, EthGetAddressParams, EthSignTxParams, BtcSignTxParams getPublicKeys: { params: any; response: any } @@ -67,11 +75,16 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { osmosisSignTx: { params: any; response: any } // TODO: type xrpSignTx: { params: any; response: any } // TODO: type solanaSignTx: { params: any; response: any } + solanaSignOffchainMessage: { params: any; response: any } tronSignTx: { params: any; response: any } + tronSignMessage: { params: any; response: any } + tronVerifyMessage: { params: any; response: any } + tronSignTypedHash: { params: any; response: any } tonSignTx: { params: any; response: any } + tonSignMessage: { params: any; response: any } // ── Pioneer integration ───────────────────────────────────────── - getBalances: { params: void; response: ChainBalance[] } + getBalances: { params: { forceRefresh?: boolean }; response: ChainBalance[] } getBalance: { params: { chainId: string }; response: ChainBalance } buildTx: { params: BuildTxParams; response: BuildTxResult } broadcastTx: { params: { chainId: string; signedTx: any }; response: BroadcastResult } @@ -103,6 +116,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { addCustomToken: { params: { chainId: string; contractAddress: string }; response: CustomToken } removeCustomToken: { params: { chainId: string; contractAddress: string }; response: void } getCustomTokens: { params: void; response: CustomToken[] } + setCustomTokenIcon: { params: { chainId: string; contractAddress: string; iconUrl: string }; response: CustomToken } addCustomChain: { params: CustomChain; response: void } removeCustomChain: { params: { chainId: number }; response: void } getCustomChains: { params: void; response: CustomChain[] } @@ -119,9 +133,22 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { zcashShieldedBalance: { params: void; response: { confirmed: number; pending: number; synced_to?: number | null; notes_total?: number; notes_unspent?: number; keepkey_release_block?: number } } zcashShieldedSend: { params: { recipient: string; amount: number; memo?: string }; response: { txid: string } } zcashShieldZec: { params: { amount: number; account?: number }; response: { txid: string } } + // Confirmed UTXO total at the user's first-receive t-addr — the only address + // shieldZec sweeps. Use this (not chain-level getBalance, which sums the + // whole xpub) to power the Shield page's Available / Max button. + // - balanceZat = mature only (≥10 conf, what shieldZec can actually spend) + // - pendingZat = under 10 conf, surfaced so the UI can explain a + // discrepancy between the chain-level balance and what's shieldable + zcashTransparentBalance: { + params: { account?: number } | void + response: { address: string; balanceZat: number; pendingZat: number; matureCount: number; pendingCount: number } + } zcashDeshieldZec: { params: { recipient: string; amount: number; account?: number }; response: { txid: string } } zcashGetTransactions: { params: void; response: { transactions: ZcashTransaction[] } } zcashBackfillMemos: { params: void; response: { backfilled: number } } + // Ask the device to derive and display its Orchard UA for this account. + // No host-cached UA or FVK material is sent for this display flow. + zcashDisplayAddress: { params: { account?: number }; response: { address: string } } // ── Pairing & Signing approval ─────────────────────────────────── approvePairing: { params: void; response: { apiKey: string } } @@ -138,6 +165,10 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { getApiLogs: { params: { limit?: number; offset?: number } | void; response: ApiLogEntry[] } clearApiLogs: { params: void; response: void } + // ── Window Focus ────────────────────────────────────────────────── + getWindowFocusState: { params: void; response: { refs: number; alwaysOnTop: boolean } } + forceReleaseWindowFocus: { params: void; response: void } + // ── App Settings ────────────────────────────────────────────────── getAppSettings: { params: void; response: AppSettings } setRestApiEnabled: { params: { enabled: boolean }; response: AppSettings } @@ -165,16 +196,57 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { // ── Swap ────────────────────────────────────────────────────────── getSwappableChainIds: { params: void; response: string[] } getSwapAssets: { params: void; response: SwapAsset[] } + /** Look up an unknown token by contract address across common chains. + * When no chainId is provided, candidate EVM chains are queried in + * parallel and any with metadata are returned. The frontend uses this + * to auto-add a token when the user pastes a contract into the asset + * picker search box. */ + lookupTokenContract: { + params: { contractAddress: string; chainId?: string } + response: { hits: SwapAsset[]; reason?: string } + } + getSwapHealth: { params: void; response: SwapHealth } getSwapQuote: { params: SwapQuoteParams; response: SwapQuote } executeSwap: { params: ExecuteSwapParams; response: SwapResult } + /** Build the unsigned swap tx(s) without signing — used to surface the + * hdwallet payload on the Confirm Quote screen for auditing. Returns + * `approveTx` only when an ERC-20 allowance bump is required. */ + previewSwapBuild: { params: ExecuteSwapParams; response: { + approveTx?: any + unsignedTx: any + allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string } + balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } + } } getPendingSwaps: { params: void; response: PendingSwap[] } dismissSwap: { params: { txid: string }; response: void } // ── Swap History (SQLite-persisted) ───────────────────────────── + getSwapByTxid: { params: { txid: string }; response: PendingSwap | null } + /** Single on-demand Pioneer poll for one swap. Used by SwapDialog while + * open — there is no background polling timer (by design). */ + refreshSwap: { params: { txid: string }; response: PendingSwap | null } + /** Read-only diagnostic for a single swap: local state + raw Pioneer + * response + rescan response, with protocol divergence flagged. Used + * by the SwapDialog "Debug" affordance and dev-tools introspection. + * Returns null when called from a passphrase-wallet session, or for + * any txid tagged as a passphrase swap. */ + debugSwapLookup: { params: { txid: string }; response: { + txid: string + pioneerBaseUrl: string | undefined + local: PendingSwap | null + pioneer: { ok: boolean; status: number | null; raw: any; error?: string } + pioneerRescan: { ok: boolean; status: number | null; raw: any; error?: string } + divergence?: { vaultProtocol: string; pioneerProtocol: string } + } | null } getSwapHistory: { params: SwapHistoryFilter | void; response: SwapHistoryRecord[] } getSwapHistoryStats: { params: void; response: SwapHistoryStats } exportSwapReport: { params: { fromDate?: number; toDate?: number; format: 'pdf' | 'csv' }; response: { filePath: string } } + // ── Swap UI mirror (WebView publishes its visible state to Bun) ── + // Fire-and-forget from the SwapDialog; Bun caches the latest snapshot + // so REST /api/v2/swap/state can read what the user sees. + publishSwapUiState: { params: SwapUiState; response: void } + // ── Recent Activity ────────────────────────────────────────────────── getRecentActivity: { params: { limit?: number; chainId?: string } | void; response: RecentActivity[] } scanChainHistory: { params: { chainId: string }; response: { count: number } } @@ -203,32 +275,53 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { // ── Emulator (macOS only — Keychain-encrypted flash) ──────────── emulatorPair: { params: void; response: EmulatorStatus } - emulatorInit: { params: { flashName?: string; channel?: 'alpha' | 'beta' | 'release' } | void; response: EmulatorStatus } + emulatorInit: { params: { flashName?: string } | void; response: EmulatorStatus } emulatorStop: { params: void; response: EmulatorStatus } emulatorSave: { params: void; response: void } emulatorStatus: { params: void; response: EmulatorStatus } - emulatorGetChannels: { params: void; response: Array<{ channel: string; version: string; description: string; installed: boolean; source: { repo: string; ref: string; type: string } }> } emulatorDeleteFlash: { params: { name: string }; response: EmulatorStatus } emulatorListWallets: { params: void; response: EmulatorWalletInfo[] } - emulatorImportWallet: { params: { name: string; mnemonic: string; label?: string; channel?: 'alpha' | 'beta' | 'release' }; response: EmulatorStatus } - emulatorSwitchWallet: { params: { name: string; channel?: 'alpha' | 'beta' | 'release' }; response: EmulatorStatus } + emulatorImportWallet: { params: { name: string; mnemonic: string; label?: string }; response: EmulatorStatus } + emulatorSwitchWallet: { params: { name: string }; response: EmulatorStatus } + /** Install a libkkemu.dylib from a base64-encoded payload into ~/.keepkey/emulator/. macOS only. */ + emulatorInstallDylib: { params: { data: string }; response: { path: string; size: number; emulatorEnabled: boolean } } // ── WalletConnect (native v2) ──────────────────────────────────── wcPair: { params: { uri: string }; response: void } wcGetSessions: { params: void; response: WcSessionInfo[] } wcDisconnectSession: { params: { topic: string }; response: void } + // Capture a screen region (macOS interactive selection) and return the PNG. + // Returns null when the user cancels the selection. Frontend decodes the + // QR with jsqr and submits the URI to wcPair. + wcScanScreen: { params: void; response: { pngBase64: string } | null } + // User responses to a pending pair-approval prompt. id is the proposal id. + wcApprovePair: { params: { id: string }; response: void } + wcRejectPair: { params: { id: string }; response: void } // ── Utility ─────────────────────────────────────────────────────── openUrl: { params: { url: string }; response: void } getPendingDeepLink: { params: void; response: string | null } consumePendingDeepLink: { params: void; response: void } + // ── Linux: udev rules auto-fix ─────────────────────────────── + // Writes /etc/udev/rules.d/51-keepkey.rules via pkexec so the user + // can talk to the device without re-running the app as root. + installLinuxUdevRules: { params: void; response: { success: boolean; error?: string } } + // ── App Updates ──────────────────────────────────────────────────── checkForUpdate: { params: void; response: UpdateInfo } downloadUpdate: { params: void; response: void } applyUpdate: { params: void; response: void } getUpdateInfo: { params: void; response: UpdateInfo | null } getAppVersion: { params: void; response: { version: string; channel: string } } + // ── REST API UI-active gate ──────────────────────────────── + // Frontend signals whether the Vault UI window is open so the REST API + // (port 1646) won't serve pubkeys/addresses to 3rd-party apps unless + // the user's UI is present. `viewDeviceId` scopes serving to the device + // the user currently has open (incl. watch-only views). + uiSetActive: { params: { active: boolean; viewDeviceId?: string | null }; response: void } + uiHeartbeat: { params: { viewDeviceId?: string | null } | void; response: void } + // ── Window controls (custom titlebar) ────────────────────── windowClose: { params: void; response: void } windowMinimize: { params: void; response: void } @@ -257,13 +350,37 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 'report-progress': { id: string; message: string; percent: number } 'walletconnect-uri': string 'wc-sessions': WcSessionInfo[] + 'wc-pair-request': { id: string; peerName: string; peerUrl: string; peerIcon: string; chains: string[]; methods: string[] } + 'wc-pair-dismiss': { id: string } + // Warm-path deep link: backend hands the URI to the frontend so the panel + // can mount *before* the WC session_proposal arrives. The pair-approval + // modal lives inside WalletConnectPanel. + 'wc-deep-link-pair': { uri: string } 'swap-update': SwapStatusUpdate 'swap-complete': PendingSwap + /** Finer-grained substage during executeSwap. The coarse `phase` enum + * (approving/signing/broadcasting) is too narrow for ERC-20 flows that + * go approve-sign → approve-broadcast → wait-receipt → swap-sign → + * swap-broadcast. Without this signal the UI shows "Approving token… + * 1/2 Waiting on KeepKey" for the entire flow including the swap step. + * Stages match the SwapSubStage type in src/bun/swap.ts. */ + 'swap-substage': { stage: 'approve-signing' | 'approve-broadcasting' | 'approve-waiting-receipt' | 'swap-signing' | 'swap-broadcasting' } + /** REST → SwapDialog command. Lets external clients drive the dialog + * the same way a user click would: open it, set a field, request a + * re-quote, or close it. Signing/broadcast are NEVER triggered this + * way — the user must press Sign in the dialog. */ + 'swap-cmd': SwapUiCommand 'scan-progress': { percent: number; scannedHeight: number; tipHeight: number; blocksPerSec: number; etaSeconds: number } 'balance-updated': ChainBalance + 'token-visibility-changed': { caip: string; status: 'visible' | 'hidden' | null } 'sweep-progress': { scanId: string; current: number; total: number; phase: string; foundCount: number; foundSats: number } 'shield-progress': { step: string; detail?: string } 'deshield-progress': { step: string; detail?: string } + 'send-progress': { step: string; detail?: string } + // Fires whenever the device emits a ButtonRequest (any flow). UI flows + // that are mid-signing use this to switch from "device computing" to + // "press the button on your KeepKey". + 'device-button-request': {} 'emulator-status': EmulatorStatus /** Bun hit an uncaught error. App process stays alive (handlers are non-exit); * UI should surface a recovery prompt and let the user reload / reconnect. */ diff --git a/projects/keepkey-vault/src/shared/spamFilter.ts b/projects/keepkey-vault/src/shared/spamFilter.ts index 77d879ba..08b765d2 100644 --- a/projects/keepkey-vault/src/shared/spamFilter.ts +++ b/projects/keepkey-vault/src/shared/spamFilter.ts @@ -1,44 +1,17 @@ /** - * Token Spam Filter — multi-tier heuristic detection. - * - * Detection order (first match wins): - * 1. User override (SQLite-persisted 'visible'/'hidden') — absolute precedence - * 2. Value floor: tokens worth >= $5 skip heuristic tiers 3-6 (real value = not spam) - * 3. Name/symbol contains URL or phishing keywords → CONFIRMED spam - * 4. Symbol has suspicious characters or excessive length → CONFIRMED spam - * 5. Known stablecoin symbol with value < $0.50 → CONFIRMED spam - * 6. Dust airdrop: huge quantity (>1M) + near-zero unit price + low value → CONFIRMED spam - * 7. Value < $0.01 → POSSIBLE spam (only "possible" — not auto-hidden) - * 8. Otherwise → clean + * Token filter — discovery-list based. + * Any token whose CAIP is not in the pioneer-discovery catalog is treated as + * unknown/spam and hidden. User overrides take absolute precedence. */ import type { TokenBalance } from './types' +import { assetData } from '@pioneer-platform/pioneer-discovery' -export const KNOWN_STABLECOINS = [ - 'USDT', 'USDC', 'DAI', 'BUSD', 'UST', 'TUSD', 'USDD', 'USDP', 'GUSD', 'PYUSD', - 'FRAX', 'LUSD', 'SUSD', 'ALUSD', 'FEI', 'MIM', 'DOLA', 'AGEUR', 'EURT', 'EURS', -] - -/** Well-known legitimate token symbols — exempt from dust-airdrop heuristic */ -const KNOWN_LEGIT_SYMBOLS = new Set([ - // Top tokens by market cap - 'ETH', 'BTC', 'WETH', 'WBTC', 'BNB', 'MATIC', 'POL', 'AVAX', 'SOL', 'DOT', - 'ADA', 'LINK', 'UNI', 'AAVE', 'MKR', 'CRV', 'COMP', 'SNX', 'SUSHI', 'YFI', - 'LDO', 'RPL', 'ARB', 'OP', 'FTM', 'ATOM', 'OSMO', 'RUNE', 'CACAO', 'XRP', - 'DOGE', 'LTC', 'BCH', 'DASH', 'ZEC', 'ETC', - // Wrapped / bridged - 'WAVAX', 'WBNB', 'WMATIC', 'WPOL', 'WFTM', - // Major stablecoins (also in KNOWN_STABLECOINS but listed here for whitelist purposes) - ...KNOWN_STABLECOINS, - // Major DeFi / governance - 'GRT', 'ENS', 'APE', 'SHIB', 'PEPE', 'WLD', 'IMX', 'RNDR', 'FET', 'OCEAN', - 'SAND', 'MANA', 'AXS', 'GALA', 'ILV', 'BLUR', 'PENDLE', 'ENA', 'ETHFI', - 'STX', 'INJ', 'TIA', 'SEI', 'SUI', 'APT', 'NEAR', 'FIL', 'AR', - // LSTs / LRTs - 'STETH', 'RETH', 'CBETH', 'WSTETH', 'SWETH', 'EETH', 'WEETH', 'METH', 'RSETH', - // FOX - 'FOX', -]) +// Build once at module load for O(1) lookups. +const _arr: Array<{ assetId: string }> = Array.isArray(assetData) + ? assetData + : Object.values(assetData as object) +const DISCOVERY_SET = new Set(_arr.map(a => a.assetId.toLowerCase())) export type SpamLevel = 'confirmed' | 'possible' | null @@ -48,129 +21,34 @@ export interface SpamResult { reason: string } -// ── Heuristic helpers ──────────────────────────────────────────────── - -/** Tokens worth at least this much skip heuristic spam checks (tiers 3-6) */ -const VALUE_FLOOR_USD = 5 - -/** URL-like patterns in name or symbol — nearly always phishing */ -const URL_PATTERN = /(?:\.[a-z]{2,6}(?:\/|$))|https?:|www\./i - -/** Phishing action words that appear in scam token names */ -const PHISHING_KEYWORDS = /\b(claim|visit|reward|bonus|airdrop|free|voucher|gift|redeem|activate|eligible)\b/i - -/** Symbols should be short alphanumeric; these chars indicate scam */ -const SUSPICIOUS_SYMBOL_CHARS = /[./:$!@#%^&*()+=\[\]{}|\\<>,?~`'"]/ - -/** Max reasonable symbol length — real tokens are 2-11 chars */ -const MAX_SYMBOL_LENGTH = 11 - /** - * Detect whether a token is spam. - * - * Call with optional `userOverride` from the token_visibility DB table. - * When a user override is present it takes absolute precedence. + * Returns whether a token should be hidden. + * User overrides (from token_visibility DB table) take absolute precedence. + * All other tokens are checked against the discovery catalog. */ export function detectSpamToken( token: TokenBalance, userOverride?: 'visible' | 'hidden' | null, ): SpamResult { - // ── Tier 0: User override — absolute precedence ────────────────── - if (userOverride === 'visible') { - return { isSpam: false, level: null, reason: 'User marked as safe' } - } - if (userOverride === 'hidden') { - return { isSpam: true, level: 'confirmed', reason: 'User marked as hidden' } - } - - const usd = token.balanceUsd ?? 0 - const sym = (token.symbol || '').toUpperCase() - const name = token.name || '' - - // ── Tier 1: Name/symbol contains URL → CONFIRMED spam ──────────── - // (Always check regardless of value — phishing tokens can be high-value) - if (URL_PATTERN.test(name) || URL_PATTERN.test(token.symbol || '')) { - return { - isSpam: true, - level: 'confirmed', - reason: `Name/symbol contains URL — phishing token`, - } - } - - // ── Tier 2: Name contains phishing keywords → CONFIRMED spam ───── - if (PHISHING_KEYWORDS.test(name)) { - return { - isSpam: true, - level: 'confirmed', - reason: `Name contains phishing keyword`, - } - } - - // ── Value floor: tokens worth >= $5 are not spam ───────────────── - // A token with real USD value is not a dust airdrop or worthless spam. - // Pioneer may return priceUsd: "0.00" for LP tokens where per-unit - // price rounds to zero, but the total valueUsd is significant. - // Skip all remaining heuristic tiers for tokens with real value. - if (usd >= VALUE_FLOOR_USD) { - return { isSpam: false, level: null, reason: `Value $${usd.toFixed(2)} above floor` } - } - - // ── Tier 3: Suspicious symbol characters or length → CONFIRMED ─── - if (SUSPICIOUS_SYMBOL_CHARS.test(token.symbol || '') || (token.symbol || '').length > MAX_SYMBOL_LENGTH) { - return { - isSpam: true, - level: 'confirmed', - reason: `Symbol has suspicious characters or is too long`, - } - } + if (userOverride === 'visible') return { isSpam: false, level: null, reason: 'User marked as safe' } + if (userOverride === 'hidden') return { isSpam: true, level: 'confirmed', reason: 'User marked as hidden' } - // ── Tier 4: Fake stablecoin (symbol matches but value way off) ─── - if (KNOWN_STABLECOINS.includes(sym) && usd < 0.50) { - return { - isSpam: true, - level: 'confirmed', - reason: `Fake ${sym} — real ${sym} is ~$1.00, this has $${usd.toFixed(2)}`, - } - } + const caip = (token.caip || '').toLowerCase() + if (!caip) return { isSpam: false, level: null, reason: 'No CAIP' } - // ── Tier 5: Dust airdrop heuristic ─────────────────────────────── - // Huge quantity + near-zero unit price = classic airdrop spam - // Exempt known legitimate tokens - if (!KNOWN_LEGIT_SYMBOLS.has(sym)) { - const qty = parseFloat(token.balance || '0') - const price = token.priceUsd ?? 0 + // Synthetic tokens injected by the vault that will never appear in the discovery catalog. + if (caip.includes('/orchard:')) return { isSpam: false, level: null, reason: 'Synthetic shielded token' } - if (qty > 1_000_000 && price < 0.0001) { - return { - isSpam: true, - level: 'confirmed', - reason: `Dust airdrop — ${qty.toLocaleString()} units at $${price.toFixed(8)}/unit`, - } - } - } - - // ── Tier 6: Near-zero value → POSSIBLE spam ────────────────────── - if (usd < 0.01) { - return { - isSpam: true, - level: 'possible', - reason: `Near-zero value ($${usd.toFixed(4)})`, - } - } + // Discovery emits BSC tokens as /bep20:; Pioneer portfolio returns /erc20: for the same assets. + const lookupCaip = caip.replace(/^eip155:56\/erc20:/, 'eip155:56/bep20:') + if (DISCOVERY_SET.has(lookupCaip)) return { isSpam: false, level: null, reason: 'In discovery catalog' } - // ── Clean — passed all checks ──────────────────────────────────── - return { isSpam: false, level: null, reason: 'Passed all spam checks' } + return { isSpam: true, level: 'confirmed', reason: 'Not in discovery catalog' } } /** - * Categorize an array of tokens using detectSpamToken. - * - * @param tokens - token array from ChainBalance.tokens - * @param overrides - Map from DB - * @returns { clean, spam, zeroValue } — mutually exclusive buckets - * - * Only "confirmed" spam is auto-hidden. "Possible" spam stays visible - * so the user can decide (and mark hidden via token_visibility if desired). + * Split a token array into clean / spam / zeroValue buckets. + * Only confirmed spam is auto-hidden. */ export function categorizeTokens( tokens: TokenBalance[], @@ -181,7 +59,7 @@ export function categorizeTokens( const zeroValue: TokenBalance[] = [] for (const t of tokens) { - const override = overrides?.get(t.caip?.toLowerCase()) ?? null + const override = overrides?.get((t.caip || '').toLowerCase()) ?? null const result = detectSpamToken(t, override) if (result.isSpam && result.level === 'confirmed') { diff --git a/projects/keepkey-vault/src/shared/swap-discovery.ts b/projects/keepkey-vault/src/shared/swap-discovery.ts new file mode 100644 index 00000000..31825a7d --- /dev/null +++ b/projects/keepkey-vault/src/shared/swap-discovery.ts @@ -0,0 +1,487 @@ +/** + * Discovery layer for the asset picker. + * + * Merges three things into one unified `AssetEntry[]`, keyed by CAIP-19: + * 1. `pioneer-discovery`'s generatedAssetData.json — the universe (~30k entries) + * 2. Pioneer's GetAvailableAssets cached list — the swappable subset + * 3. The user's per-chain balances — for "do I hold this?" + * + * Search index is built lazily on first call so the 30k pre-process doesn't + * happen unless the dialog actually opens. After that it's cached for the + * session. + */ +import type { SwapAsset, ChainBalance, CustomToken } from './types' +import { CHAINS } from './chains' +import { COIN_MAP_LONG } from '@pioneer-platform/pioneer-coins' +import { assessAvailability, normalizeChainCaip2, CHAIN_CAIP2_ALIASES, type AvailabilityAssessment } from './swap-support-matrix' +// Static-imported chains metadata — ~218KB, used synchronously by +// networkDisplayName + chainMetaForCaip2 from both bun and frontend. Vite +// inlines as a JSON module. The bigger generatedAssetData.json (~10MB) stays +// lazy because the picker is the only consumer and the user may never open it. +import discoveryChainsJson from '@pioneer-platform/pioneer-discovery/lib/chains.json' + +/** Vault chain id → canonical THORChain short prefix. Built from + * `pioneer-coins` `COIN_MAP_LONG` (which maps THOR prefix → chain id) plus + * vault-specific overrides where pioneer-coins's chain id disagrees with + * vault's, and where it conflates THORChain *chain* prefixes with asset + * *symbols* (THORChain memos use GAIA.ATOM, THOR.RUNE — chain prefix wins). + * Defined here in shared/ so both the picker (frontend) and synthesis path + * can use it without crossing into bun/. */ +export const VAULT_CHAIN_TO_THOR: Record = (() => { + const out: Record = {} + for (const [thorPrefix, chainId] of Object.entries(COIN_MAP_LONG as Record)) { + if (!out[chainId]) out[chainId] = thorPrefix + } + // pioneer-coins uses 'binance' for BSC; vault uses 'bsc'. + out.bsc = 'BSC' + // pioneer-coins's first hits put symbols (ATOM, RUNE) under cosmos/thorchain + // — restore the THORChain *chain* prefix that memos use. + out.cosmos = 'GAIA' + out.thorchain = 'THOR' + out.mayachain = 'MAYA' + out.tron = 'TRON' // THORChain memos use TRON.TRX (not TRX.TRX) + return out +})() + +/** Shape of one entry inside pioneer-discovery's generatedAssetData.json. */ +interface DiscoveryAssetRaw { + symbol: string + name?: string + chainId: string + assetId: string + decimals: number + icon?: string + type?: string + isNative?: boolean + color?: string +} + +export interface AssetEntry { + /** CAIP-19 — primary key */ + caip: string + symbol: string + name: string + /** CAIP-2 chain id */ + chainId: string + decimals: number + iconUrl?: string + /** True for native chain assets, false for tokens. */ + isNative: boolean + /** Holdings — present iff user has a balance for this asset on the connected chain. */ + balance?: { amount: string; usd: number } + /** Asset name in THORChain format (BTC.BTC, ETH.USDT-0x...) — only present if Pioneer reports it as swappable. */ + swappableAsset?: string + /** SwapAsset reference — only present if Pioneer's GetAvailableAssets included it. */ + swappable?: SwapAsset + availability: AvailabilityAssessment +} + +/** Sort key bucket — lower numbers float to the top. */ +export type SortBucket = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 + +/** Compute the bucket each entry falls into. Sub-bucketing native vs token + * prevents long-tail tokens from outranking real native assets when the + * user types a generic symbol like "BTC". */ +export function bucketFor(entry: AssetEntry): SortBucket { + const usd = entry.balance?.usd || 0 + if (usd > 0) return 0 // held + valued + if (entry.balance) return 1 // held + zero-USD + if (entry.swappable) return entry.isNative ? 2 : 3 // Pioneer-confirmed + if (entry.availability.status === 'swappable') return entry.isNative ? 4 : 5 + if (entry.availability.status === 'unknown') return 6 + return 7 // unsupported +} + +/** Compare two entries: bucket asc → bucket-specific tiebreak. */ +export function compareEntries(a: AssetEntry, b: AssetEntry): number { + const ba = bucketFor(a) + const bb = bucketFor(b) + if (ba !== bb) return ba - bb + + if (ba === 0) return (b.balance!.usd) - (a.balance!.usd) + + const symA = a.symbol.toUpperCase() + const symB = b.symbol.toUpperCase() + if (symA !== symB) return symA < symB ? -1 : 1 + return a.name < b.name ? -1 : 1 +} + +// ── Lazy search index ──────────────────────────────────────────────────── + +let cachedDiscovery: Map | null = null + +async function getDiscoveryMap(): Promise> { + if (cachedDiscovery) return cachedDiscovery + // Dynamic import keeps the 30k JSON out of the initial bundle parse if the + // user never opens the asset picker. + const mod = await import('@pioneer-platform/pioneer-discovery') + const data = (mod as any).assetData as Record + cachedDiscovery = new Map(Object.entries(data)) + return cachedDiscovery +} + +/** Test-only: drop the cached discovery map. */ +export function _resetDiscoveryCacheForTests(): void { + cachedDiscovery = null +} + +// ── Network labels + chain CAIP-19 lookup ─────────────────────────────── + +interface ChainMeta { + /** Vault internal chain id (e.g. 'bitcoin', 'ethereum') — needed for + * SwapAsset.chainId which downstream code uses to resolve config. */ + vaultChainId: string + /** Full CAIP-19 native asset id for the chain (e.g. 'eip155:1/slip44:60') — + * what AssetIcon's chainCaip prop expects. */ + nativeCaip: string + /** Display name (e.g. 'Ethereum'). */ + displayName: string + chainFamily: string +} + +let cachedChainMetaByCaip2: Map | null = null + +/** Canonicalize a chain CAIP-2 (no-op for already-canonical values). Delegates + * to swap-support-matrix's table so the matrix and the picker dedupe path + * share a single source — adding a new alias there fixes both at once. */ +export function canonicalizeChainCaip2(chainCaip2: string): string { + return normalizeChainCaip2(chainCaip2) +} + +/** Token-namespace prefixes that mean "this CAIP describes a contract token, + * not a chain-native asset". Native assets use `/slip44:N` regardless of + * chain family. BEP-20 was missing from the picker's earlier isNative check, + * which silently classified BSC tokens as native and stripped their contract + * address during synthesis — caller used native BNB balances instead. */ +const TOKEN_NAMESPACES = ['/erc20:', '/bep20:', '/token:'] as const + +/** Parse a CAIP-19 into its components. Returns the contract address (raw + * case-preserving) for token CAIPs, undefined for natives. Single source so + * swap-discovery, swap.ts, and synthesizeSwapAsset agree. */ +export function parseCaip(caip: string): { + chainCaip2: string + isToken: boolean + /** Token contract address — raw case (TRON tokens are case-sensitive). */ + contractAddress?: string +} { + const slash = caip.indexOf('/') + if (slash < 0) return { chainCaip2: caip, isToken: false } + const chainCaip2 = caip.slice(0, slash) + const tail = caip.slice(slash) // includes leading '/' + for (const ns of TOKEN_NAMESPACES) { + if (tail.startsWith(ns)) { + return { chainCaip2, isToken: true, contractAddress: tail.slice(ns.length) } + } + } + return { chainCaip2, isToken: false } +} + +/** BSC tokens are equivalently expressible as `/erc20:` (CAIP-19 standard for + * EVM tokens) or `/bep20:` (BSC-specific extension pioneer-discovery emits). + * Pioneer-server's quote endpoint only routes the `/erc20:` form — sending + * `/bep20:` returns "No quotes available" (verified live 2026-05). Fold to + * `/erc20:` at canonicalization so the picker, matrix, and outgoing quote + * CAIP all agree. */ +function canonicalizeTokenNamespace(caip: string): string { + // Only BSC has the bep20/erc20 split; other chains' namespaces are unique. + return caip.replace(/^eip155:56\/bep20:/, 'eip155:56/erc20:') +} + +/** Canonicalize a full CAIP-19 by remapping the chain prefix when it has + * alternate encodings, AND folding the token namespace where multiple forms + * exist. Both `tron:27Lqcw/slip44:195` and `tron:27lqcw/slip44:195` collapse + * to `tron:0x2b6653dc/slip44:195`; `eip155:56/bep20:0x...` collapses to + * `eip155:56/erc20:0x...`. */ +export function canonicalizeCaip(caip: string): string { + const slash = caip.indexOf('/') + if (slash < 0) return canonicalizeChainCaip2(caip) + const chain = caip.slice(0, slash) + const canonical = canonicalizeChainCaip2(chain) + const withChain = canonical === chain ? caip : `${canonical}${caip.slice(slash)}` + return canonicalizeTokenNamespace(withChain) +} + +/** Build CAIP-2 → ChainMeta map. Memoized — CHAINS is static. Both canonical + * and alternate encodings populate the map so chainMetaForCaip2 resolves + * pioneer-discovery's TRON entries (base58) to vault's TRON ChainDef (hex). */ +function getChainMetaMap(): Map { + if (cachedChainMetaByCaip2) return cachedChainMetaByCaip2 + const out = new Map() + for (const c of CHAINS) { + if (!c.networkId) continue + const meta: ChainMeta = { + vaultChainId: c.id, + nativeCaip: c.caip, + displayName: c.coin, + chainFamily: c.chainFamily, + } + out.set(c.networkId, meta) + // Also register any alternate encodings that map TO this chain. + for (const [alt, canonical] of Object.entries(CHAIN_CAIP2_ALIASES)) { + if (canonical === c.networkId && !out.has(alt)) out.set(alt, meta) + } + } + cachedChainMetaByCaip2 = out + return out +} + +/** Discovery chains.json — keyed by CAIP-2, covers ~700 chains including + * encoding-mismatched TRON forms (both tron:27Lqcw base58 and tron:0x2b6653dc + * hex resolve to "TRON"). Used as a fallback when vault's CHAINS table doesn't + * know the chain — drives human-readable reason text in the asset picker. */ +const discoveryChains = discoveryChainsJson as Record + +/** Human-readable display name for a CAIP-2 network id, even when vault + * doesn't have a ChainDef for it. Falls through: vault CHAINS → discovery + * chains.json → raw CAIP-2 string. */ +export function networkDisplayName(caip2: string): string { + const meta = getChainMetaMap().get(caip2) + if (meta) return meta.displayName + const discovery = discoveryChains[caip2] + if (discovery?.name) return discovery.name + return caip2 +} + +/** Get chain metadata for a CAIP-2 network id. Returns null if vault doesn't + * know the chain (e.g. Pioneer-discovery has it but we have no ChainDef). + * Use `networkDisplayName` instead when you only need a friendly chain name. */ +export function chainMetaForCaip2(caip2: string): ChainMeta | null { + return getChainMetaMap().get(caip2) || null +} + +/** Construct a SwapAsset shape from an AssetEntry. Used when the user picks a + * row Pioneer didn't pre-list (matrix-swappable or unknown) — downstream + * quote/execute code still expects the SwapAsset interface, but we only have + * AssetEntry data. The synthesized SwapAsset round-trips through Pioneer; if + * Pioneer rejects it the existing quote-error UX surfaces the reason. */ +export function synthesizeSwapAsset(entry: AssetEntry): { + asset: string + chainId: string + symbol: string + name: string + chainFamily: string + decimals: number + caip: string + icon?: string + contractAddress?: string +} | null { + const meta = chainMetaForCaip2(entry.chainId) + if (!meta) return null // unknown chain — caller should refuse the selection + + // Single parser, namespace-aware. Was previously homemade and missed `/bep20:` + // entirely — BSC tokens lost their contract address during synthesis and the + // SwapDialog rendered native BNB pricing for them. + const { isToken, contractAddress } = parseCaip(entry.caip) + + // Pioneer's `asset` field (THORChain-style "CHAIN.SYMBOL-CONTRACT") is + // load-bearing for the `assetToCaip` reconstruct path, which splits on `.` + // and looks up the chain prefix in THOR_TO_CHAIN. Using the displayName + // ("OPTIMISM") instead of the canonical short prefix ("OP") would throw + // "Unsupported THORChain chain: OPTIMISM" the moment the user picks a + // VELO-class long-tail token. Look up the canonical THORChain prefix per + // vault chain id; fall back to displayName-uppercase for chains we haven't + // mapped (defensive, paired with long-form aliases in THOR_TO_CHAIN). + const chainShort = VAULT_CHAIN_TO_THOR[meta.vaultChainId] + ?? meta.displayName.split(/\s+/)[0].toUpperCase() + const asset = isToken && contractAddress + ? `${chainShort}.${entry.symbol}-${contractAddress.toUpperCase()}` + : `${chainShort}.${entry.symbol}` + + return { + asset, + chainId: meta.vaultChainId, + symbol: entry.symbol, + name: entry.name, + chainFamily: meta.chainFamily, + decimals: entry.decimals, + caip: entry.caip, + icon: entry.iconUrl, + contractAddress, + } +} + +// ── Public API ────────────────────────────────────────────────────────── + +export interface BuildEntriesInput { + /** Pioneer GetAvailableAssets cached result (the swappable subset). */ + swappable: SwapAsset[] + /** Connected wallet's per-chain balances (with optional token sub-arrays). */ + balances: ChainBalance[] + /** User-added contract tokens that aren't in pioneer-discovery or Pioneer's + * swappable list. Without these, freshly-added long-tail tokens (e.g. a + * meme on Base) wouldn't appear in the picker even after persistence. */ + customTokens?: CustomToken[] +} + +/** Build the unified, sorted asset list. Async to allow lazy import of + * pioneer-discovery's 30k JSON. */ +export async function buildAssetEntries(input: BuildEntriesInput): Promise { + const discovery = await getDiscoveryMap() + const swappableByCaip = new Map() + for (const s of input.swappable) { + if (s.caip) swappableByCaip.set(s.caip, s) + } + + // Index user balances by CAIP for O(1) lookup. Tokens carry their own CAIP; + // the native chain CAIP is resolved via the static CHAINS table since + // ChainBalance only carries the internal chainId (e.g. 'bitcoin'). + const chainIdToCaip = new Map() + for (const c of CHAINS) { + if (c.caip) chainIdToCaip.set(c.id, c.caip) + } + const balanceByCaip = new Map() + for (const cb of input.balances) { + const nativeCaip = chainIdToCaip.get(cb.chainId) + if (nativeCaip) { + balanceByCaip.set(nativeCaip, { + amount: cb.balance, + usd: cb.nativeBalanceUsd ?? 0, + }) + } + if (cb.tokens) { + for (const tok of cb.tokens) { + if (tok.caip) balanceByCaip.set(tok.caip, { amount: tok.balance, usd: tok.balanceUsd || 0 }) + } + } + } + + // Canonicalize keys so duplicate chain encodings collapse. pioneer-discovery + // has 3 TRX entries (tron:27Lqcw, tron:27lqcw, tron:0x2b6653dc); without + // this dedupe the picker rendered all three. + const seen = new Set() + const entries: AssetEntry[] = [] + // Walk the discovery universe — every CAIP becomes a row (after canonicalization). + for (const [rawCaip, raw] of discovery) { + const caip = canonicalizeCaip(rawCaip) + if (seen.has(caip)) continue + seen.add(caip) + const swappable = swappableByCaip.get(caip) ?? swappableByCaip.get(rawCaip) + const balance = balanceByCaip.get(caip) ?? balanceByCaip.get(rawCaip) + const availability = assessAvailability(caip) + // CAIP namespace is the source of truth — `/slip44:` is native, anything + // under `/erc20:` `/bep20:` or `/token:` is a token. Discovery's own + // isNative/type fields can disagree (saw it lie about BEP-20s). + const { isToken } = parseCaip(caip) + const isNative = !isToken + entries.push({ + caip, + symbol: raw.symbol, + name: raw.name || raw.symbol, + chainId: canonicalizeChainCaip2(raw.chainId), + decimals: raw.decimals, + iconUrl: raw.icon, + isNative, + balance, + swappable, + swappableAsset: swappable?.asset, + availability, + }) + } + + // Pioneer can return a swappable asset that isn't in our discovery JSON + // (rare — usually new tokens). Backfill so the picker still surfaces them. + // SwapAsset.chainId is vault's internal id (e.g. 'tron'), not CAIP-2 — so + // we extract the CAIP-2 chain prefix from caip itself for AssetEntry.chainId + // (which downstream consumers expect to be CAIP-2). + for (const [rawCaip, s] of swappableByCaip) { + const caip = canonicalizeCaip(rawCaip) + if (seen.has(caip)) continue + seen.add(caip) + const slash = caip.indexOf('/') + const chainCaip2 = slash >= 0 ? caip.slice(0, slash) : caip + entries.push({ + caip, + symbol: s.symbol, + name: s.name, + chainId: chainCaip2, + decimals: s.decimals, + iconUrl: s.icon, + isNative: !s.contractAddress, + balance: balanceByCaip.get(caip), + swappable: s, + swappableAsset: s.asset, + availability: assessAvailability(caip), + }) + } + + // User-added custom tokens — fall through here when neither discovery nor + // Pioneer's swappable list cover them (long-tail meme/community tokens). + // The aggregator routing matrix (assessAvailability) still gates whether + // they're swappable; we just make them visible + selectable in the picker. + for (const ct of input.customTokens || []) { + const rawCaip = `${ct.networkId}/erc20:${ct.contractAddress}` + const caip = canonicalizeCaip(rawCaip) + if (seen.has(caip)) continue + seen.add(caip) + entries.push({ + caip, + symbol: ct.symbol, + name: ct.name || ct.symbol, + chainId: canonicalizeChainCaip2(ct.networkId), + decimals: ct.decimals, + iconUrl: ct.iconUrl, + isNative: false, + balance: balanceByCaip.get(caip), + swappable: undefined, + swappableAsset: undefined, + availability: assessAvailability(caip), + }) + } + + entries.sort(compareEntries) + return entries +} + +/** Pre-computed lowercase fields for ranked substring search. */ +export interface SearchIndex { + entries: AssetEntry[] + symbols: string[] + names: string[] + caips: string[] +} + +export function buildSearchIndex(entries: AssetEntry[]): SearchIndex { + const symbols = new Array(entries.length) + const names = new Array(entries.length) + const caips = new Array(entries.length) + for (let i = 0; i < entries.length; i++) { + symbols[i] = entries[i].symbol.toLowerCase() + names[i] = entries[i].name.toLowerCase() + caips[i] = entries[i].caip.toLowerCase() + } + return { entries, symbols, names, caips } +} + +/** Match rank: 0 exact, 1 prefix, 2 contains, -1 no match. Symbol and name + * collapse into the same rank so "name exact" ties with "symbol exact" — the + * bucket then disambiguates based on actual user holdings/swap support. */ +function rankMatch(symbol: string, name: string, caip: string, q: string): number { + if (symbol === q || name === q) return 0 + if (symbol.startsWith(q) || name.startsWith(q)) return 1 + if (symbol.includes(q) || name.includes(q) || caip.includes(q)) return 2 + return -1 +} + +/** Ranked filter. Empty query returns input unchanged (already bucket-sorted). + * + * Composite score: `matchRank * 10 + bucket`. Match rank weighted higher so: + * - "bitcoin" finds BTC (rank 0 + bucket 4 = 4) before "BITCOIN" memecoins + * (rank 0 + bucket 6 = 6) ✓ + * - "tron" finds TRX even at bucket 7 unsupported (rank 0 + bucket 7 = 7) + * before unknown EVM tokens with "tron" in the name (rank 1 + bucket 6 = 16) ✓ + * - "btc" still surfaces actual BTC (rank 0 + bucket 4 = 4) before + * tokens that contain "BTC" as a substring (rank 2 + bucket 4+ ≥ 24) ✓ */ +export function searchEntries(index: SearchIndex, query: string): AssetEntry[] { + const q = query.trim().toLowerCase() + if (!q) return index.entries + + const matched: Array<{ score: number; idx: number }> = [] + for (let i = 0; i < index.entries.length; i++) { + const r = rankMatch(index.symbols[i], index.names[i], index.caips[i], q) + if (r < 0) continue + const score = r * 10 + bucketFor(index.entries[i]) + matched.push({ score, idx: i }) + } + matched.sort((a, b) => (a.score - b.score) || (a.idx - b.idx)) + return matched.map(m => index.entries[m.idx]) +} diff --git a/projects/keepkey-vault/src/shared/swap-revert.ts b/projects/keepkey-vault/src/shared/swap-revert.ts new file mode 100644 index 00000000..607aa4f5 --- /dev/null +++ b/projects/keepkey-vault/src/shared/swap-revert.ts @@ -0,0 +1,31 @@ +/** + * Pure receipt → swap-status decision used by the EVM revert detector. + * + * Lives here (shared/, no I/O imports) instead of in swap-tracker.ts so the + * decision is unit-testable without dragging the whole bun + sqlite stack + * into the test runner. swap-tracker.ts re-exports for the runtime path. + */ +import type { SwapTrackingStatus } from './types' + +export type RevertDecision = { status: 'failed'; error: string; blockNumber: number } + +/** Given the current swap status and a one-shot receipt result, decide whether + * the swap should now be marked failed (and what the user-facing error says). + * + * - Already-terminal swaps return null (idempotent). + * - No receipt yet (null) returns null — caller should keep polling. + * - Successful receipt (status === true) returns null — let normal pipeline take over. + * - Reverted receipt (status === false) returns the failure decision. */ +export function decideRevertOutcome( + currentStatus: SwapTrackingStatus, + receipt: { status: boolean; blockNumber: number } | null, +): RevertDecision | null { + if (currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'refunded') return null + if (!receipt) return null + if (receipt.status !== false) return null + return { + status: 'failed', + error: 'Transaction reverted on-chain — gas spent, asset NOT delivered. Common causes: insufficient allowance, slippage tripped, or contract reverted.', + blockNumber: receipt.blockNumber, + } +} diff --git a/projects/keepkey-vault/src/shared/swap-support-matrix.ts b/projects/keepkey-vault/src/shared/swap-support-matrix.ts new file mode 100644 index 00000000..e57b9081 --- /dev/null +++ b/projects/keepkey-vault/src/shared/swap-support-matrix.ts @@ -0,0 +1,312 @@ +/** + * Static client-side swap-provider support matrix. + * + * Phase 1: every assessment is a pure function of (caip → known-provider-sets). + * No Pioneer round-trip, no quote probes. Returns an honest `unknown` when the + * chain is supported by some provider but the specific token isn't in our + * static list — UI uses that to say "try a quote" instead of falsely + * green-lighting or red-flagging. + * + * The matrix encodes well-known coverage as of 2026-05. It is NOT exhaustive: + * - native chain support is well-documented and stable enough to hardcode + * - well-known stablecoins (USDC/USDT/DAI) on each EVM chain are hardcoded + * - long-tail tokens fall through to `unknown` on supported chains + * + * Stale-risk: pool composition changes. If the matrix says "swappable" and + * Pioneer rejects the quote, the existing quote-error UX still applies — this + * is purely a *predictive* hint, not a contract. + */ + +export type SwapProvider = + | 'thorchain' + | 'mayachain' + | 'relay' + | 'zeroex' // 0x — single-chain EVM + | 'chainflip' + | 'shapeshift' // ShapeShift Swapper — aggregates LiFi/Squid/Across over EVMs + +export type AvailabilityStatus = + | 'swappable' // covered by ≥1 provider's hardcoded set + | 'unknown' // chain is supported but this token isn't in our static list — try a quote + | 'unsupported_token' // chain is supported but token namespace doesn't match any provider's token universe (rare) + | 'unsupported_chain' // no provider routes this chain at all + +export interface AvailabilityAssessment { + status: AvailabilityStatus + /** Providers that support this asset directly. Empty for unknown/unsupported. */ + providers: SwapProvider[] + /** Short user-facing reason. Present for non-`swappable` statuses. */ + reason?: string +} + +// ── CAIP-2 chain IDs supported by each provider (native swaps) ────────── +// Source: pioneer-server's ENABLED_ASSETS_V1 whitelist + provider docs as of +// 2026-05. Encodings match what pioneer-server emits (canonical CAIP-2); +// normalizeChainCaip2 below remaps pioneer-discovery's alternate encodings +// (notably TRON's base58 vs hex genesis hash) so both sides agree. + +/** Canonical chain CAIP-2 → alternate encoding(s) emitted by other tools. + * + * Pioneer-discovery emits TRON in three encodings (`tron:27Lqcw`, + * `tron:27lqcw`, `tron:0x2b6653dc`); pioneer-server and Relay use the hex + * genesis hash. Matrix is keyed on the canonical form Relay/pioneer-server + * use; alternates hit normalizeChainCaip2 first. + * + * Exported so swap-discovery.ts can use the same table — keeps the alias list + * single-source. Adding to this map both fixes matrix lookups AND the picker's + * duplicate-row dedupe in one edit. + * + * Note on Hyperliquid: pioneer-discovery and vault's CHAINS table agree on + * `eip155:2868` but the actual Hyperliquid mainnet chainId per chainID.network + * is 999. Relay routes 999. We previously aliased 2868→999 here, but vault's + * ChainDef doesn't have a 999 entry, so any picker click would silently fail + * (synthesizeSwapAsset returned null). Until vault's CHAINS table is + * reconciled, Hyperliquid is intentionally absent from RELAY_CHAINS / + * SHAPESHIFT_CHAINS — picker shows it as unsupported_chain with a clear + * reason rather than letting it look swappable and break on click. */ +export const CHAIN_CAIP2_ALIASES: Record = { + // alternate → canonical + 'tron:27Lqcw': 'tron:0x2b6653dc', + 'tron:27lqcw': 'tron:0x2b6653dc', // case-insensitive defensive +} + +/** Normalize a chain CAIP-2 to the canonical encoding the matrix uses. + * No-op for already-canonical values. Exported alongside the alias table. */ +export function normalizeChainCaip2(chainCaip2: string): string { + return CHAIN_CAIP2_ALIASES[chainCaip2] || chainCaip2 +} + +/** Fold BSC's `/bep20:` namespace into the standard `/erc20:` form so matrix + * lookups (and downstream Pioneer Quote calls) only have to know one + * encoding. pioneer-server's quote endpoint returns "No quotes available" + * for `/bep20:` BSC USDT but routes the same asset cleanly under `/erc20:` + * (verified live 2026-05). Pure string op — no I/O. */ +function normalizeTokenNamespace(caip: string): string { + return caip.replace(/^eip155:56\/bep20:/, 'eip155:56/erc20:') +} + +/** UTXO + EVM + Cosmos + Solana + TRON chains routable by THORChain pools. + * Verified against pioneer-server's ENABLED_ASSETS_V1 (2026-05). */ +const THORCHAIN_CHAINS = new Set([ + 'bip122:000000000019d6689c085ae165831e93', // BTC + 'bip122:12a765e31ffd4059bada1e25190f6e98', // LTC + 'bip122:000000000000000000651ef99cb9fcbe', // BCH + 'bip122:00000000001a91e3dace36e2be3bf030', // DOGE (was wrong hash before — pioneer-server is source of truth) + 'bip122:000007d91d1254d60e2dd1ae58038307', // DASH (Maya routes too) + 'eip155:1', // ETH + 'eip155:43114', // AVAX C-chain + 'eip155:56', // BSC + 'eip155:8453', // BASE + 'eip155:42161', // ARB + 'eip155:10', // OP + // Polygon (eip155:137) historically had a THORChain pool but it was + // deprecated; Pioneer-server lists it but matrix omits to avoid false- + // positive "swappable" hints. Aggregators (Relay/0x) still route MATIC. + 'cosmos:thorchain-mainnet-v1', // RUNE + 'cosmos:cosmoshub-4', // ATOM (GAIA) + 'tron:0x2b6653dc', // TRX — verified live via pioneer-server + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // SOL — added 2026-05 per pioneer-server comment +]) + +/** Mayachain native pools. */ +const MAYACHAIN_CHAINS = new Set([ + 'bip122:000000000019d6689c085ae165831e93', // BTC + 'eip155:1', // ETH + 'eip155:42161', // ARB + 'cosmos:mayachain-mainnet-v1', // CACAO + 'cosmos:thorchain-mainnet-v1', // RUNE + 'cosmos:kaiyo-1', // KUJI + 'bip122:000007d91d1254d60e2dd1ae58038307', // DASH + // ZEC pool on Maya — verified live 2026-05-09. Transparent (t1...) inbound only; + // shielded deposits are not accepted by the protocol vault. + 'bip122:00040fe8ec8471911baa1db1266ea15d', // ZEC +]) + +/** Relay (Reservoir solver network) — EVM cross-chain. Coverage list verified + * 2026-05 by probing pioneer-server's /quote endpoint with ETH source against + * each chain's native asset. Anything that returns a quote stays in this set; + * chains that returned "no quotes" (e.g. Hyperliquid, Fantom, Sei, + * PolygonZkEVM, Moonbeam from ETH) remain off the list — UI will mark them + * unsupported_chain unless a different provider picks them up. */ +const RELAY_CHAINS = new Set([ + // Tier-1 verified against pioneer-server live (2026-05-08) + 'eip155:1', // Ethereum + 'eip155:10', // Optimism + 'eip155:56', // BSC + 'eip155:100', // Gnosis + 'eip155:137', // Polygon + 'eip155:143', // Monad + 'eip155:146', // Sonic + 'eip155:169', // Manta Pacific + 'eip155:324', // zkSync Era + 'eip155:5000', // Mantle + 'eip155:8453', // Base + 'eip155:34443', // Mode + 'eip155:42161', // Arbitrum + 'eip155:42220', // Celo + 'eip155:43114', // Avalanche + 'eip155:59144', // Linea + 'eip155:80094', // Berachain + 'eip155:81457', // Blast + 'eip155:534352', // Scroll + // Hyperliquid (eip155:999) is omitted — see CHAIN_CAIP2_ALIASES note above. +]) + +/** 0x — single-chain EVM aggregator. */ +const ZEROEX_CHAINS = new Set([ + 'eip155:1', 'eip155:10', 'eip155:56', 'eip155:137', + 'eip155:8453', 'eip155:42161', 'eip155:43114', +]) + +/** ChainFlip native cross-chain swaps. */ +const CHAINFLIP_CHAINS = new Set([ + 'bip122:000000000019d6689c085ae165831e93', // BTC + 'eip155:1', // ETH + 'eip155:42161', // ARB + 'polkadot:91b171bb158e2d3848fa23a9f1c25182', // DOT + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // SOL +]) + +/** ShapeShift Swapper — pioneer-server's `shapeshiftSwap` integration which + * aggregates LiFi/Squid/Across solvers across the broader EVM landscape. + * Currently mirrors RELAY_CHAINS at the chain level (Relay is the dominant + * underlying solver as of 2026-05); kept as a separate set so we can extend + * to non-Relay coverage as the integration grows. Confirmed against + * pioneer-server's swappers/shapeshift-swap module. */ +const SHAPESHIFT_CHAINS = new Set([ + 'eip155:1', 'eip155:10', 'eip155:56', 'eip155:100', + 'eip155:137', 'eip155:143', 'eip155:146', 'eip155:169', + 'eip155:324', 'eip155:5000', 'eip155:8453', 'eip155:34443', + 'eip155:42161', 'eip155:42220', 'eip155:43114', 'eip155:59144', + 'eip155:80094', 'eip155:81457', 'eip155:534352', +]) + +// ── Well-known stablecoins per chain (CAIP-19 token IDs) ──────────────── +// Hardcoded so we can confidently say "USDT-on-Ethereum is swappable" without +// a quote round-trip. Anything else on these chains falls through to `unknown`. +// +// Token namespace convention: BSC tokens are keyed as `/erc20:` here to match +// what pioneer-server's quote endpoint accepts. Pioneer-discovery emits +// `/bep20:` for the same assets — the canonicalize() step below folds bep20 +// into erc20 before the lookup hits this set. + +const STABLECOIN_TOKENS = new Set([ + // USDT + 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', // ETH + 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955', // BSC + 'eip155:137/erc20:0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // POLYGON + 'eip155:42161/erc20:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', // ARB + 'eip155:10/erc20:0x94b008aa00579c1307b0ef2c499ad98a8ce58e58', // OP + 'eip155:43114/erc20:0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', // AVAX + 'eip155:8453/erc20:0xfde4c96c8593536e31f229ea8f37b2ada2699bb2', // BASE (USDT) + // USDC + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // ETH + 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', // BSC + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // POLYGON (native) + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831', // ARB (native) + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', // OP (native) + 'eip155:43114/erc20:0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', // AVAX (native) + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // BASE (native) + // DAI + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', // ETH + // TRON USDT — verified live in pioneer-server's ENABLED_ASSETS_V1 (THORChain) + 'tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', +]) + +/** Subset of stablecoins that THORChain explicitly pools. Keeps the matrix + * honest — Mayachain/Relay/0x have wider stablecoin coverage. TRON USDT + * routes via THORChain. */ +const THORCHAIN_TOKEN_PREFIXES = [ + 'eip155:1/erc20:', + 'eip155:43114/erc20:', + 'eip155:56/erc20:', + 'tron:0x2b6653dc/token:', +] + +// ── Public API ────────────────────────────────────────────────────────── + +/** Given a CAIP-19 asset id, decide which providers route it (if any) and + * return a user-friendly assessment. Pure — no I/O. */ +export function assessAvailability(caip: string): AvailabilityAssessment { + if (!caip) { + return { status: 'unsupported_chain', providers: [], reason: 'No asset id' } + } + + const slash = caip.indexOf('/') + const rawChainId = slash >= 0 ? caip.slice(0, slash) : caip + // Normalize alternate encodings before set lookup so pioneer-discovery and + // pioneer-server agree on identity: + // - chain-prefix aliases (TRON base58 ↔ hex) + // - token namespace fold (BSC `/bep20:` → `/erc20:`) + // The full CAIP gets both treatments so STABLECOIN_TOKENS lookups hit. + const chainId = normalizeChainCaip2(rawChainId) + const chainSwapped = chainId !== rawChainId + ? `${chainId}${caip.slice(rawChainId.length)}` + : caip + const normalizedCaip = normalizeTokenNamespace(chainSwapped) + const isToken = slash >= 0 && !caip.includes('/slip44:') + + const providers: SwapProvider[] = [] + + if (!isToken) { + // Native asset: whole-chain support sets apply. + if (THORCHAIN_CHAINS.has(chainId)) providers.push('thorchain') + if (MAYACHAIN_CHAINS.has(chainId)) providers.push('mayachain') + if (RELAY_CHAINS.has(chainId)) providers.push('relay') + if (ZEROEX_CHAINS.has(chainId)) providers.push('zeroex') + if (CHAINFLIP_CHAINS.has(chainId)) providers.push('chainflip') + if (SHAPESHIFT_CHAINS.has(chainId)) providers.push('shapeshift') + + if (providers.length > 0) return { status: 'swappable', providers } + return { + status: 'unsupported_chain', + providers: [], + reason: `${chainId} is not currently supported by any swap provider`, + } + } + + // Token path. Token-specific providers first, then fall through. Use the + // chain-normalized CAIP for the well-known stablecoin lookup so TRON USDT + // matches whether the input encoded TRON as base58 or hex. + if (STABLECOIN_TOKENS.has(normalizedCaip)) { + if (RELAY_CHAINS.has(chainId)) providers.push('relay') + if (ZEROEX_CHAINS.has(chainId)) providers.push('zeroex') + if (THORCHAIN_TOKEN_PREFIXES.some(p => normalizedCaip.startsWith(p))) providers.push('thorchain') + if (providers.length > 0) return { status: 'swappable', providers } + } + + // Token on a chain Relay/0x/ShapeShift cover → unknown (try a quote — most + // ERC-20s on these chains route fine through the aggregator solvers). + if (RELAY_CHAINS.has(chainId) || ZEROEX_CHAINS.has(chainId) || SHAPESHIFT_CHAINS.has(chainId)) { + return { + status: 'unknown', + providers: [], + reason: 'Token may be swappable via aggregators — try a quote to confirm', + } + } + + // Token on a chain we route natives for, but no aggregator presence. + if (THORCHAIN_CHAINS.has(chainId) || MAYACHAIN_CHAINS.has(chainId) || CHAINFLIP_CHAINS.has(chainId)) { + return { + status: 'unsupported_token', + providers: [], + reason: 'Native chain is supported but this specific token is not currently routable', + } + } + + return { + status: 'unsupported_chain', + providers: [], + reason: `${chainId} is not currently supported by any swap provider`, + } +} + +/** Provider → display label, used by AssetPickerDialog tooltips. */ +export const PROVIDER_LABEL: Record = { + thorchain: 'THORChain', + mayachain: 'Mayachain', + relay: 'Relay', + zeroex: '0x', + chainflip: 'ChainFlip', + shapeshift: 'ShapeShift', +} diff --git a/projects/keepkey-vault/src/shared/swap-warnings.ts b/projects/keepkey-vault/src/shared/swap-warnings.ts new file mode 100644 index 00000000..2c097d0f --- /dev/null +++ b/projects/keepkey-vault/src/shared/swap-warnings.ts @@ -0,0 +1,68 @@ +/** + * Pure derivation for swap warning surfaces (dust-fee + high-slippage). + * + * Kept side-effect-free and dependency-free so the same logic is testable in + * isolation and could later be reused server-side. UI is responsible for + * rendering — these helpers only decide what the warning *says*. + */ + +/** % of input value lost to fees+spread before we surface a warning at all. */ +export const DUST_FEE_WARNING_PCT = 10 +/** % of input value lost to fees+spread that escalates to "severe" styling. */ +export const DUST_FEE_SEVERE_PCT = 25 +/** Effective slippage % above which we warn the user. Effective = max(market, tolerance). */ +export const HIGH_SLIPPAGE_PCT = 3 + +export type DustWarning = { + severe: boolean + /** % of input lost to fees+spread (0–100) */ + lossPct: number + /** USD value of input */ + inUsd: number + /** USD value lost (inUsd - outUsd) */ + lostUsd: number + /** Suggested minimum input USD for non-dust swap (severe tier only) */ + recommendedMinUsd: number +} + +/** Compute a dust-fee warning from displayed in/out USD values. Returns null + * when no warning should fire (insufficient data, profitable swap, sub-threshold loss). + * Inputs are taken from the same numbers shown to the user, so msg.value EVM + * fees and protocol spread are captured implicitly — not just quote.fees. */ +export function computeDustWarning(input: { + inAmount: number + outAmount: number + fromPriceUsd: number + toPriceUsd: number +}): DustWarning | null { + const { inAmount, outAmount, fromPriceUsd, toPriceUsd } = input + if (!Number.isFinite(inAmount) || !Number.isFinite(outAmount)) return null + if (!Number.isFinite(fromPriceUsd) || !Number.isFinite(toPriceUsd)) return null + const inUsd = inAmount * fromPriceUsd + const outUsd = outAmount * toPriceUsd + if (inUsd <= 0 || outUsd <= 0) return null + const lossPct = ((inUsd - outUsd) / inUsd) * 100 + if (lossPct < DUST_FEE_WARNING_PCT) return null + return { + severe: lossPct > DUST_FEE_SEVERE_PCT, + lossPct, + inUsd, + lostUsd: inUsd - outUsd, + recommendedMinUsd: Math.ceil(inUsd * 4), + } +} + +/** Effective slippage for the warning surface. The market slippage Pioneer + * observed (quote.slippageBps) is often <1% even when the user has set a 5% + * tolerance — so checking only the quote misses the user-set risk. Take the + * max of both and warn against that. */ +export function computeEffectiveSlippageBps(quoteSlippageBps: number, userSlippageBps: number): number { + const q = Number.isFinite(quoteSlippageBps) ? Math.max(0, quoteSlippageBps) : 0 + const u = Number.isFinite(userSlippageBps) ? Math.max(0, userSlippageBps) : 0 + return Math.max(q, u) +} + +/** Boolean form of the slippage warning. Threshold is HIGH_SLIPPAGE_PCT. */ +export function shouldWarnHighSlippage(quoteSlippageBps: number, userSlippageBps: number): boolean { + return computeEffectiveSlippageBps(quoteSlippageBps, userSlippageBps) / 100 > HIGH_SLIPPAGE_PCT +} diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 6b798964..ecc9bed4 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -41,6 +41,11 @@ export interface DeviceStateInfo { /** True when using a hidden wallet (non-empty passphrase). Reports and chain * history are unavailable; no data is persisted to disk for privacy. */ isHiddenWallet: boolean + /** Linux only — set when a KeepKey is enumerated on the USB bus but neither + * WebUSB nor HID could open it. Almost always means /etc/udev/rules.d + * is missing the 51-keepkey.rules entry. The UI surfaces an auto-fix flow + * (writes the rule via pkexec, reloads udev). */ + linuxUdevPermissionDenied?: boolean } export interface FirmwareProgress { @@ -114,6 +119,7 @@ export interface ChainBalance { nativeBalanceUsd?: number // native-only USD (excludes tokens) address: string tokens?: TokenBalance[] + updatedAt?: number // unix ms — when this chain's balance was last confirmed non-zero from Pioneer } export interface BuildTxParams { @@ -125,6 +131,7 @@ export interface BuildTxParams { 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' + nativeBalance?: string // human-readable native balance (from frontend) — avoids re-fetch on max send tokenBalance?: string // human-readable token balance (from frontend) — avoids re-fetch on max send tokenDecimals?: number // token decimals (from frontend) — avoids re-fetch xpubOverride?: string // BTC multi-account: use this xpub instead of default @@ -192,10 +199,20 @@ export interface BtcAccountSet { } // ── EVM multi-address types ───────────────────────────────────────── +export interface EvmAddressChainBalance { + chainId: string + symbol: string + balance: string + balanceUsd: number + nativeBalanceUsd: number + tokens?: TokenBalance[] +} + export interface EvmTrackedAddress { addressIndex: number // derivation index (m/44'/60'/0'/0/{index}) address: string // 0x-prefixed checksummed address balanceUsd: number // aggregate USD across all EVM chains + chainBalances?: Record } export interface EvmAddressSet { @@ -222,6 +239,7 @@ export interface CustomToken { name: string decimals: number networkId: string // CAIP-2 (e.g. 'eip155:137') + iconUrl?: string // resolved logo (TrustWallet/CoinGecko); undefined when neither matched } export interface CustomChain { @@ -288,6 +306,46 @@ export interface EIP712DecodedInfo { isKnownType: boolean } +/** + * Decoded form of an EIP-191 `personal_sign` payload for the approval UI. + * + * The `/eth/sign` REST endpoint accepts `message` as a hex string per the + * Ethereum JSON-RPC spec. In practice that hex almost always encodes UTF-8 + * text (SIWE auth, dApp login challenges, etc.) — the user needs to see + * that text to know what they're signing. We surface both forms so the UI + * can show the decoded string prominently while still giving access to the + * raw bytes for hash comparison. + */ +export interface EthMessageDecodedInfo { + /** Signer address (from the request body) — shown so the user confirms which account. */ + address: string + /** Raw message exactly as received on the wire (typically `0x`-prefixed hex). */ + messageRaw: string + /** Message interpreted as UTF-8 text. Undefined when the bytes are not valid UTF-8. */ + messageText?: string + /** True when `messageRaw` looked like hex and successfully round-tripped to UTF-8. */ + isUtf8Text: boolean +} + +export interface SolanaMessageDecodedInfo { + /** Signer pubkey/address shown so the user confirms which account is signing. */ + signer?: string + /** Raw message value exactly as supplied by the caller. */ + messageRaw: string + /** Best-effort input encoding used to turn `messageRaw` into bytes for display checks. */ + encoding: 'base58' | 'base64' | 'hex' | 'utf8' + /** Message interpreted as UTF-8 text. Undefined when the bytes are not valid UTF-8. */ + messageText?: string + /** Raw bytes as hex for byte-for-byte inspection. */ + messageHex: string + /** Number of message bytes being signed. */ + byteLength: number + /** Best-effort sanity check: raw text, opaque bytes, or something shaped like a Solana tx. */ + classification: 'text-message' | 'binary-message' | 'solana-transaction' | 'solana-transaction-message' + /** Diagnostic from the transaction/message shape check, shown only when useful. */ + sanityCheck?: string +} + // ── Calldata clear-signing types ───────────────────────────────────────── export interface CalldataDecodedField { @@ -323,8 +381,24 @@ export interface SigningRequestInfo { chainId?: number typedDataDecoded?: EIP712DecodedInfo calldataDecoded?: CalldataDecodedInfo // Clear-signing: decoded contract calldata + /** Clear-signing: decoded EIP-191 personal_sign message. Always set for /eth/sign requests. */ + ethMessageDecoded?: EthMessageDecodedInfo + /** Clear-signing: decoded raw Solana message signing payload. */ + solanaMessageDecoded?: SolanaMessageDecodedInfo + /** Clear-signing: decoded Solana tx — per-instruction rows + resolved ALT accounts */ + solanaDecoded?: SolanaTxDecodedInfo + /** + * Populated when a Solana transaction was received but clear-sign decoding + * failed (malformed wire layout, unsupported message version, RPC outage, + * etc.). The UI uses this to show an explicit "could not clear-sign" + * warning instead of silently downgrading the approval dialog to the + * generic simple-transfer view. + */ + solanaDecodeError?: string /** true when tx has calldata that cannot be fully decoded — device will show blind-signing warning */ needsBlindSigning?: boolean + /** true when the UI must enable AdvancedMode before allowing approval */ + requiresAdvancedMode?: boolean /** true when device AdvancedMode policy is currently enabled */ advancedModeEnabled?: boolean /** Device firmware version string e.g. "7.14.0" — used to gate blind-signing UI */ @@ -333,8 +407,47 @@ export interface SigningRequestInfo { rawRequestBody?: Record } +/** + * Clear-signing output for a Solana transaction. Produced by the + * Vault-side decoder (src/bun/solana-instruction-decoder.ts) and surfaced + * in SigningApproval so the user sees human-readable per-instruction rows + * instead of opaque program ids and raw hex. Mirrors the structure used + * by the future firmware Insight metadata path. + */ +export interface SolanaTxDecodedInstructionArg { + name: string + type: 'u8' | 'u16' | 'u32' | 'u64' | 'bool' | 'pubkey' | 'string' | 'bytes' + value: string +} +export interface SolanaTxDecodedInstructionAccount { + label?: string + pubkey: string +} +export interface SolanaTxDecodedInstruction { + status: 'known' | 'known-program-unknown-ix' | 'unknown-program' + programId: string + programName: string + programCategory?: string + instructionName?: string + args: SolanaTxDecodedInstructionArg[] + accounts: SolanaTxDecodedInstructionAccount[] + note?: string +} +export interface SolanaTxDecodedInfo { + version: 'legacy' | 'v0' + instructions: SolanaTxDecodedInstruction[] + /** base58 ALT pubkeys referenced by the tx. Empty for legacy. */ + altPubkeys: string[] + /** True when at least one ALT couldn't be resolved — UI should warn. */ + altResolutionIncomplete?: boolean + /** True when at least one instruction is from an unknown program. */ + hasUnknownProgram?: boolean +} + export interface ApiLogEntry { id?: number // SQLite rowid (set after DB insert) + deviceId?: string // active hardware device id at the time of the log + walletId?: string // device+seed scope at the time of the log method: string route: string timestamp: number @@ -406,6 +519,12 @@ export interface EmulatorWalletInfo { name: string hasMnemonic: boolean isActive: boolean + /** On-device label (Settings → Label). Populated after first connect. */ + label?: string + /** Hardware-style deviceId returned by Features. Used to join cached balances. */ + deviceId?: string + /** Sum of cached balance USD across chains for this wallet's deviceId. */ + totalUsd?: number } /** Persisted device snapshot — one per device_id, stored in SQLite. */ @@ -564,6 +683,10 @@ export interface RelayTxParams { maxFeePerGas?: string maxPriorityFeePerGas?: string chainId: number + /** True for deposit-channel protocols (Chainflip, NEAR Intents EVM side) where + * `data` is intentionally empty — the swap destination was registered off-chain + * when the quote/channel was created. Skips the empty-calldata cross-chain guard. */ + isDepositChannel?: boolean } /** Quote response from Pioneer (aggregated across DEXes) */ @@ -582,47 +705,74 @@ export interface SwapQuote { 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", "relay", etc. + /** Underlying protocol when `integration` is an aggregator. ShapeShift's swapper + * routes through Relay / THORChain / 0x / Uniswap / Curve / etc.; this names + * which one will actually execute so the user sees it before signing. + * Undefined for non-aggregator integrations (use `integration` directly). */ + swapper?: string relayTx?: RelayTxParams // pre-built tx for relay/bridge integrations (skips memo+router flow) + minAmountIn?: string // minimum sell amount for this route (human-readable, in sell asset units) } -/** Parameters for getSwapQuote RPC */ +/** Parameters for getSwapQuote RPC. + * + * CAIP is the only identifier the swap stack accepts. Pioneer's Quote + * endpoint takes CAIP directly and dispatches to the right swapper + * (THORChain / Mayachain / ShapeShift / Relay / 0x). Symbols and + * THORChain-style asset names are display concerns — derived from CAIP + * by the tracker, not passed as parameters. */ 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%) + fromCaip: string // CAIP-19 of the sell asset + toCaip: string // CAIP-19 of the buy asset + amount: string // human-readable amount + fromAddress: string // sender address + toAddress: string // destination address + slippageBps?: number // slippage tolerance (default 300 = 3%) } -/** Parameters for executeSwap RPC */ +/** Parameters for executeSwap RPC. CAIP-only identification — the tracker + * resolves symbol/name/asset-string for display from the CAIP via + * pioneer-discovery. Caller never specifies a token via symbol. */ 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 + fromChainId: string // our chain id (resolves to ChainDef for signing) + toChainId: string // our chain id + fromCaip: string // CAIP-19 — primary identifier + toCaip: string // CAIP-19 — primary identifier + amount: string // human-readable amount + memo: string // THORChain routing memo (empty for memoless integrations) + 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 - fromAddressOverride?: string // pre-resolved sender address (skips defaultPath derivation) - toAddressOverride?: string // pre-resolved destination address (skips defaultPath derivation) - integration?: string // DEX source (relay quotes skip memo+router flow) - relayTx?: RelayTxParams // pre-built tx for relay/bridge integrations + fromAddressOverride?: string // pre-resolved sender address (skips defaultPath derivation) + toAddressOverride?: string // pre-resolved destination address (skips defaultPath derivation) + integration?: string // DEX source (relay quotes skip memo+router flow) + relayTx?: RelayTxParams // pre-built tx for relay/bridge integrations +} + +export type SwapProviderStatus = 'ok' | 'degraded' | 'offline' | 'unknown' + +export interface SwapIntegrationHealth { + key: string // 'thorchain' | 'mayachain' | 'shapeshift' | 'relay' | 'chainflip' + label: string + status: SwapProviderStatus + haltedPools?: string[] // CAIP-19s of suspended/staged pools + detail?: string // one-line reason, e.g. "2 pools halted or suspended" +} + +export interface SwapHealth { + fetchedAt: number + integrations: SwapIntegrationHealth[] } /** Result of executeSwap RPC */ export interface SwapResult { txid: string - fromAsset: string - toAsset: string + fromCaip: string + toCaip: string fromAmount: string expectedOutput: string approvalTxid?: string @@ -633,6 +783,8 @@ export interface SwapResult { export type SwapTrackingStatus = 'signing' | 'pending' | 'confirming' | 'output_detected' | 'output_confirming' | 'output_confirmed' | 'completed' | 'failed' | 'refunded' export interface PendingSwap { + deviceId?: string // active hardware device id when the swap was submitted + walletId?: string // device+seed scope when the swap was submitted txid: string fromAsset: string // THORChain asset id (e.g. "BASE.ETH") toAsset: string // THORChain asset id (e.g. "ETH.ETH") @@ -640,12 +792,16 @@ export interface PendingSwap { toSymbol: string fromChainId: string // our chain id toChainId: string + fromCaip?: string // CAIP-19 — preserved so the resumed dialog can render the asset logo + toCaip?: string fromAmount: string // human-readable - expectedOutput: string // human-readable + expectedOutput: string // human-readable (quote-time estimate; replaced with actual when received) + receivedOutput?: string // actual received amount (filled by Pioneer poll once outbound confirms) memo: string inboundAddress: string router?: string integration: string // "thorchain", "shapeshift", etc. + swapper?: string // underlying protocol when integration is an aggregator (e.g. "Relay", "Thorchain", "0x") status: SwapTrackingStatus confirmations: number outboundConfirmations?: number @@ -653,8 +809,26 @@ export interface PendingSwap { outboundTxid?: string createdAt: number // unix ms updatedAt: number // unix ms + completedAt?: number // unix ms — when terminal status reached estimatedTime: number // seconds error?: string + slippageBps?: number // slippage tolerance used at quote time (preserved across resumes) + /** Relay's bytes32 request id (lowercase, 0x-prefixed). Extracted from the + * inbound deposit calldata at trackSwap time, or backfilled lazily by + * refreshSwap via api.relay.link. Drives the "Relay Track" external link + * on relay/shapeshift integrations. */ + relayRequestId?: string + /** Vault chain id of the actual outbound (refunds outbound on the source chain, + * not the destination). Populated by the Maya/Thor classifier — used to route + * the explorer link to the correct chain. Falls back to toChainId when absent. */ + outboundChainId?: string + /** Reason text from a Maya/Thor refund, when status='refunded'. */ + refundReason?: string + /** Set true when classifySwapOutcome (Midgard) has populated this record. + * Once set, Pioneer's mapPioneerStatus is no longer authoritative — Pioneer + * cannot distinguish "swap completed" from "refund completed", and would + * otherwise ping-pong status with Midgard on every refresh. */ + midgardClassified?: boolean } export interface SwapStatusUpdate { @@ -665,11 +839,27 @@ export interface SwapStatusUpdate { outboundRequiredConfirmations?: number outboundTxid?: string error?: string + /** Underlying protocol detected by the tracker (e.g. "thorchain", "mayachain", + * "Relay"). Pioneer surfaces this in `details.protocol.protocol` even when + * the original quote response didn't include it — most reliable post-broadcast. */ + swapper?: string + /** Relay request id (bytes32 hex). Set when the lazy backfill in refreshSwap + * resolves it via api.relay.link, so the UI can render the tracker link + * without a full re-fetch. */ + relayRequestId?: string + /** Vault chain id of the actual outbound. Refunds outbound on the source + * chain, completions on the destination chain — Midgard's action.out asset + * is the only authority. UI uses this to pick the explorer URL. */ + outboundChainId?: string + /** Refund reason surfaced from the source chain (Midgard) when status='refunded'. */ + refundReason?: string } /** Persisted swap history record (SQLite) — tracks the full lifecycle */ export interface SwapHistoryRecord { id: string // unique row id (UUID) + deviceId?: string // active hardware device id when the swap was submitted + walletId?: string // device+seed scope when the swap was submitted txid: string // inbound transaction hash fromAsset: string // THORChain asset id toAsset: string @@ -677,6 +867,8 @@ export interface SwapHistoryRecord { toSymbol: string fromChainId: string toChainId: string + fromCaip?: string // CAIP-19 (preserved for icon resolution on resume) + toCaip?: string fromAmount: string // human-readable amount sent quotedOutput: string // expected output at quote time minimumOutput: string // minimum after slippage at quote time @@ -685,6 +877,7 @@ export interface SwapHistoryRecord { feeBps: number // total fee in basis points feeOutbound: string // outbound gas fee quoted integration: string // "thorchain", "shapeshift", "chainflip" + swapper?: string // underlying protocol when integration is an aggregator memo: string inboundAddress: string // vault address router?: string @@ -697,10 +890,22 @@ export interface SwapHistoryRecord { estimatedTimeSeconds: number // estimated time at quote time actualTimeSeconds?: number // actual duration (completedAt - createdAt) approvalTxid?: string // ERC-20 approval tx (if applicable) + /** Relay request id (bytes32 hex, lowercase). Persisted so the resume path + * can render the "Relay Track" external link without re-querying. */ + relayRequestId?: string + /** Chain id of the actual outbound. For refunds this is the source chain + * (Maya returns the inbound asset on the inbound chain), so the explorer + * link must use this — not toChainId. Populated by the Maya midgard + * classifier in swap-tracker; falls back to toChainId when absent. */ + outboundChainId?: string + /** Refund reason from Midgard when status='refunded'. */ + refundReason?: string } /** Filter params for getSwapHistory RPC */ export interface SwapHistoryFilter { + deviceId?: string + walletId?: string status?: SwapTrackingStatus | 'all' fromDate?: number // unix ms toDate?: number // unix ms @@ -718,6 +923,60 @@ export interface SwapHistoryStats { pending: number } +// ── Swap UI mirror (REST → UI control) ──────────────────────────────── + +export type SwapUiPhase = 'closed' | 'input' | 'quoting' | 'review' | 'approving' | 'signing' | 'broadcasting' | 'submitted' + +/** Snapshot of the SwapDialog visible state. Published by the WebView on every + * meaningful state change so REST clients (and Bun internals) can observe what + * the user sees without scraping the DOM. */ +export interface SwapUiState { + phase: SwapUiPhase + fromAsset: string | null // THORChain asset id (e.g. 'BTC.BTC') + toAsset: string | null + amount: string // crypto-denominated user input + fiatAmount: string + inputMode: 'crypto' | 'fiat' + isMax: boolean + slippageBps: number + fromAddress: string + toAddress: string + useCustomAddress: boolean + customToAddress: string + quote: SwapQuote | null + /** Unsigned tx(s) built ahead of confirm — populated when phase==='review'. + * `allowance` describes the current ERC-20 allowance state vs required — + * populated for ERC-20 source swaps so the UI can show "approval needed" + * vs "✓ already approved" without ambiguity. */ + previewBuild: { + approveTx?: any + unsignedTx: any + allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string } + balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } + } | null + error: string | null + txid: string | null + trackingStatus?: SwapTrackingStatus | null + confirmations?: number + outboundConfirmations?: number + outboundRequiredConfirmations?: number + outboundTxid?: string | null + relayRequestId?: string | null + refundReason?: string | null +} + +/** Bun → WebView commands: nudge the SwapDialog the same way a user click + * would. The physical KeepKey button press still requires the user — REST + * can drive every UI button up to the device prompt, then the user must + * confirm on hardware. */ +export type SwapUiCommand = + | { kind: 'open'; fromAsset?: string; toAsset?: string; amount?: string; slippageBps?: number; useCustomAddress?: boolean; customToAddress?: string } + | { kind: 'set'; fromAsset?: string; toAsset?: string; amount?: string; slippageBps?: number; inputMode?: 'crypto' | 'fiat'; isMax?: boolean; useCustomAddress?: boolean; customToAddress?: string } + | { kind: 'requote' } + | { kind: 'advance' } // input → review (UI navigation only) + | { kind: 'confirm' } // click "Confirm Swap" → kicks off executeSwap + | { kind: 'close' } + // ── Recent Activity types ────────────────────────────────────────────── export type ActivityType = 'send' | 'receive' | 'swap' | 'sign' | 'message' | 'approve' @@ -725,6 +984,8 @@ export type ActivitySource = 'app' | 'api' | 'scan' export interface RecentActivity { id: string + deviceId?: string + walletId?: string txid?: string // blockchain txid (may be absent for sign-only before broadcast) chain: string // chain symbol (BTC, ETH, ATOM, etc.) chainId?: string // internal chain id (bitcoin, ethereum, etc.) — for explorer links @@ -736,6 +997,12 @@ export interface RecentActivity { appName?: string // for API-originating activities status: 'signed' | 'broadcast' | 'completed' | 'refunded' | 'failed' swapStatus?: SwapTrackingStatus // detailed swap lifecycle status (only for type === 'swap') + // ── Swap-only output side (only set when type === 'swap') ── + outAmount?: string // received_output if completed, else quoted_output + outAsset?: string // toSymbol + outChainId?: string // toChainId — for the output asset's chain badge / explorer + fromCaip?: string // CAIP-19 for input asset (icon resolution) + toCaip?: string // CAIP-19 for output asset (icon resolution) createdAt: number // ── On-chain confirmation data (populated by scan, updated on rescan) ── confirmations?: number // current confirmation count (0 = unconfirmed/mempool) diff --git a/projects/keepkey-vault/tests/emulator/harness.ts b/projects/keepkey-vault/tests/emulator/harness.ts index 46638f26..01806284 100644 --- a/projects/keepkey-vault/tests/emulator/harness.ts +++ b/projects/keepkey-vault/tests/emulator/harness.ts @@ -10,8 +10,9 @@ * h.shutdown() */ import { dlopen, FFIType, ptr } from 'bun:ffi' -import { resolve } from 'path' -import { readFileSync, existsSync } from 'fs' +import { join } from 'path' +import { existsSync } from 'fs' +import { homedir } from 'os' import * as core from '@keepkey/hdwallet-core' import { Adapter, Transport, type TransportDelegate } from '@keepkey/hdwallet-keepkey' @@ -21,7 +22,9 @@ const FLASH_SIZE = 1048576 const PACKET_SIZE = 64 const POLL_MS = 5 const READ_TIMEOUT_MS = 10_000 // 10s for tests (not 120s) -const MANIFEST_PATH = resolve(__dirname, '../../../../firmware/emulators/manifest.json') +// Tests load whichever dylib the developer has installed at the same path +// the production app uses. Run `make build-emulator` first. +const DYLIB_PATH = join(homedir(), '.keepkey', 'emulator', 'libkkemu.dylib') // Standard python-keepkey test mnemonic (same as tests/common.py mnemonic12) export const TEST_MNEMONIC = 'alcohol woman abuse must during monitor noble actual mixed trade anger aisle' @@ -38,15 +41,10 @@ interface EmuFFI { } function loadDylib(): { symbols: EmuFFI; close: () => void } { - const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) - const entry = manifest.emulators.find( - (e: any) => e.platform === process.platform && e.arch === process.arch - ) - if (!entry) throw new Error(`No emulator for ${process.platform}/${process.arch}`) - const dylibPath = resolve(MANIFEST_PATH, '..', entry.dylib) - if (!existsSync(dylibPath)) throw new Error(`Dylib not found: ${dylibPath}`) - - return dlopen(dylibPath, { + if (!existsSync(DYLIB_PATH)) { + throw new Error(`Emulator dylib not installed at ${DYLIB_PATH}. Run: make build-emulator`) + } + return dlopen(DYLIB_PATH, { kkemu_init: { args: [FFIType.ptr, FFIType.u64], returns: FFIType.i32 }, kkemu_shutdown: { args: [], returns: FFIType.void }, kkemu_write: { args: [FFIType.ptr, FFIType.u64, FFIType.i32], returns: FFIType.i32 }, diff --git a/projects/keepkey-vault/zcash-cli/Cargo.lock b/projects/keepkey-vault/zcash-cli/Cargo.lock index 1280055e..96b6f4e5 100644 --- a/projects/keepkey-vault/zcash-cli/Cargo.lock +++ b/projects/keepkey-vault/zcash-cli/Cargo.lock @@ -2957,6 +2957,7 @@ dependencies = [ "hex", "incrementalmerkletree 0.8.2", "log", + "nonempty 0.11.0", "orchard 0.12.0", "pasta_curves", "prost", @@ -2977,6 +2978,7 @@ dependencies = [ "zcash_keys", "zcash_note_encryption", "zcash_primitives", + "zcash_protocol 0.4.3", "zcash_protocol 0.7.2", "zip32 0.2.1", ] diff --git a/projects/keepkey-vault/zcash-cli/Cargo.toml b/projects/keepkey-vault/zcash-cli/Cargo.toml index dcb60147..e18ba6b7 100644 --- a/projects/keepkey-vault/zcash-cli/Cargo.toml +++ b/projects/keepkey-vault/zcash-cli/Cargo.toml @@ -62,6 +62,11 @@ sha2 = "0.10" [dev-dependencies] tempfile = "3.10" +nonempty = "0.11" +# zcash_primitives 0.19 internally pins zcash_protocol 0.4, so its public +# `Transaction::read(_, BranchId)` API expects the 0.4 BranchId. Our crate +# uses 0.7 elsewhere; alias the 0.4 version for tests only. +zcash_protocol_v04 = { package = "zcash_protocol", version = "0.4" } [build-dependencies] tonic-build = "0.12" diff --git a/projects/keepkey-vault/zcash-cli/src/main.rs b/projects/keepkey-vault/zcash-cli/src/main.rs index 07c88f97..b313b4f1 100644 --- a/projects/keepkey-vault/zcash-cli/src/main.rs +++ b/projects/keepkey-vault/zcash-cli/src/main.rs @@ -19,6 +19,11 @@ use orchard::keys::FullViewingKey; use zcash_address::unified::{self, Container, Encoding}; use zcash_protocol::consensus::NetworkType; +/// Minimum confirmations required before a note is treated as spendable. +/// Matches zcashd / ywallet / zecwallet defaults; protects against small +/// reorgs and lightwalletd tree-state lag (see `wallet_db::get_spendable_notes`). +const MIN_CONFIRMATIONS: u64 = 10; + /// Global state persisted across IPC commands within a single sidecar session. struct State { db: Option, @@ -512,11 +517,24 @@ async fn handle_balance(state: &mut State, _params: &Value) -> Result { let (total, unspent) = db.get_note_count()?; let synced_to = db.last_scanned_height()?; + // Spendable view: only notes at depth >= MIN_CONFIRMATIONS from `synced_to` + // (proxy for tip — exact when scan is at tip, conservative when behind). + // The build_*_pczt paths use the same filter; UI controls (e.g. the deshield + // "Max" button) need this view so they don't propose amounts the builder + // would later reject as "all unspent notes are within N confs". + let max_h = synced_to.unwrap_or(0).saturating_sub(MIN_CONFIRMATIONS); + let spendable_notes = db.get_spendable_notes(Some(max_h))?; + let spendable_confirmed: u64 = spendable_notes.iter().map(|n| n.value).sum(); + let spendable_count = spendable_notes.len() as u64; + Ok(serde_json::json!({ "confirmed": balance, "pending": 0, "notes_total": total, "notes_unspent": unspent, + "spendable_confirmed": spendable_confirmed, + "spendable_notes_count": spendable_count, + "min_confirmations": MIN_CONFIRMATIONS, "synced_to": synced_to, "keepkey_release_block": scanner::KEEPKEY_RELEASE_BLOCK, })) @@ -546,18 +564,35 @@ async fn handle_build_pczt(state: &mut State, params: &Value) -> Result { // Parse recipient address — supports UA (u1...), transparent (t1...), or raw hex let recipient = parse_recipient_address(recipient_str)?; - // Get spendable notes + // Connect first so we can ask the chain for its tip + branch id together. + let mut lwd_client = scanner::LightwalletClient::connect(None).await?; + let branch_id = lwd_client.get_consensus_branch_id().await?; + info!("Using consensus branch ID: 0x{:08x}", branch_id); + + // Min-confirmations: skip notes mined within the last MIN_CONFIRMATIONS + // blocks. A note received in the last few blocks can fail the chain's + // Halo2 proof verification on broadcast — its position in the local tree + // may differ from the chain's after a small reorg, or lightwalletd's + // tree state may lag the cmx scan. 10 matches zcashd / ywallet defaults. + let tip = lwd_client.get_latest_block_height().await?; + let max_block_height = tip.saturating_sub(MIN_CONFIRMATIONS); + let db = state.ensure_db()?; - let notes = db.get_spendable_notes()?; + let notes = db.get_spendable_notes(Some(max_block_height))?; if notes.is_empty() { + // Either truly empty, or every note is too recent. Distinguish so the + // user sees an actionable message instead of "no spendable notes". + let total_unspent = db.get_spendable_notes(None)?.len(); + if total_unspent > 0 { + return Err(anyhow::anyhow!( + "All {} unspent notes are within {} confirmations of the chain tip ({}). \ + Wait a few minutes and retry.", + total_unspent, MIN_CONFIRMATIONS, tip, + )); + } return Err(anyhow::anyhow!("No spendable notes — scan first")); } - // Query current consensus branch ID from lightwalletd - let mut lwd_client = scanner::LightwalletClient::connect(None).await?; - let branch_id = lwd_client.get_consensus_branch_id().await?; - info!("Using consensus branch ID: 0x{:08x}", branch_id); - // Build PCZT with real chain tree data let pczt_state = pczt_builder::build_pczt( &fvk, notes, recipient, amount, account, branch_id, @@ -748,17 +783,28 @@ async fn handle_build_deshield_pczt(state: &mut State, params: &Value) -> Result } }; - // Get spendable notes + let mut lwd_client = scanner::LightwalletClient::connect(None).await?; + let branch_id = lwd_client.get_consensus_branch_id().await?; + info!("Using consensus branch ID: 0x{:08x}", branch_id); + + // Min-confirmations gate (see handle_build_pczt for rationale). + let tip = lwd_client.get_latest_block_height().await?; + let max_block_height = tip.saturating_sub(MIN_CONFIRMATIONS); + let db = state.ensure_db()?; - let notes = db.get_spendable_notes()?; + let notes = db.get_spendable_notes(Some(max_block_height))?; if notes.is_empty() { + let total_unspent = db.get_spendable_notes(None)?.len(); + if total_unspent > 0 { + return Err(anyhow::anyhow!( + "All {} unspent notes are within {} confirmations of the chain tip ({}). \ + Wait a few minutes and retry.", + total_unspent, MIN_CONFIRMATIONS, tip, + )); + } return Err(anyhow::anyhow!("No spendable notes — scan first")); } - let mut lwd_client = scanner::LightwalletClient::connect(None).await?; - let branch_id = lwd_client.get_consensus_branch_id().await?; - info!("Using consensus branch ID: 0x{:08x}", branch_id); - // Build the transparent output(s) let transparent_output = pczt_builder::DeshieldTransparentOutput { script_pubkey: hex::encode(&script_pubkey), diff --git a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs index 63130c6d..19686f40 100644 --- a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs +++ b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs @@ -42,6 +42,24 @@ fn zip317_fee(n_spends: usize, n_outputs: usize) -> u64 { ZIP317_MARGINAL_FEE * std::cmp::max(ZIP317_GRACE_ACTIONS, logical_actions) } +/// ZIP-317 fee for a deshield tx (Orchard spends → 1 transparent output, 1 change note). +/// +/// Per ZIP-317 §3, `logical_actions` uses the FINAL Orchard `action_count` +/// (post-padding) — not the pre-padding `max(n_spends, n_outputs)`. +/// `BundleType::DEFAULT` pads to a 2-action minimum for the anonymity set, +/// so a 1-spend deshield has 2 orchard actions on chain. +/// +/// Underpaying triggers the chain's "Unpaid actions is higher than the limit" +/// mempool rejection even when the orchard proof verifies cleanly — because +/// `unpaid_actions = ceil((expected_fee - actual_fee) / marginal_fee) > 0`. +fn zip317_deshield_fee(n_spends: usize) -> u64 { + const N_TRANSPARENT_ACTIONS: u64 = 1; // one transparent output + const N_ORCHARD_OUTPUTS: usize = 1; // change note + let orchard_actions = std::cmp::max(2, std::cmp::max(n_spends, N_ORCHARD_OUTPUTS)) as u64; + let logical_actions = orchard_actions + N_TRANSPARENT_ACTIONS; + ZIP317_MARGINAL_FEE * std::cmp::max(ZIP317_GRACE_ACTIONS, logical_actions) +} + /// Per-action fields needed by the device for signing + digest verification. #[derive(Debug, Clone, Serialize)] #[allow(dead_code)] @@ -1523,15 +1541,8 @@ pub async fn build_deshield_pczt( let mut rng = OsRng; let total_input: u64 = notes.iter().map(|n| n.value).sum(); - // ZIP-317 fee for a deshield tx: - // Orchard actions = max(n_spends, n_orchard_outputs) where n_orchard_outputs = change only - // Transparent logical actions = max(0 inputs, 1 output) = 1 let n_spends = notes.len(); - let n_orchard_outputs = 1usize; // change output (or dummy pad) - let orchard_actions = std::cmp::max(n_spends, n_orchard_outputs); - let transparent_actions = 1usize; // one transparent output - let logical_actions = orchard_actions + transparent_actions; - let fee = ZIP317_MARGINAL_FEE * std::cmp::max(ZIP317_GRACE_ACTIONS, logical_actions as u64); + let fee = zip317_deshield_fee(n_spends); let change = total_input.checked_sub(amount + fee) .ok_or_else(|| anyhow::anyhow!( @@ -1676,6 +1687,68 @@ pub async fn build_deshield_pczt( } } + // Extend the local tree past the last completed shard up to the chain + // tip, mirroring the shield path's incomplete-shard fetch (lines ~998-1063). + // Without this, the tree only reflects state at the end of the last + // shard containing a spent note — but the chain's anchor at lwd_tip_height + // includes every commitment after that, so the locally-computed root + // can never match `get_orchard_anchor(lwd_tip_height)` and the build + // fails with "Orchard anchor mismatch". + // + // Skip this when our notes are already in the latest incomplete shard; + // that shard's per-note loop above already walks to the tip (shard_end_pos + // = u64::MAX, shard_end_height = lwd_tip_height), so a second pass here + // would double-append leaves. + let last_completed_shard = subtree_roots.len() as u32; + let last_completed_height = subtree_roots.last().map(|(_, _, h)| *h).unwrap_or(1687104); + if !note_shards.contains(&last_completed_shard) && lwd_tip_height > last_completed_height { + let shard_start_pos = (last_completed_shard as u64) * SHARD_SIZE; + let tree_size_before_completing = if last_completed_height > 0 { + lwd_client.get_orchard_tree_size_at(last_completed_height - 1).await? + } else { 0 }; + let tree_size_after_completing = lwd_client.get_orchard_tree_size_at(last_completed_height).await?; + let plan = plan_incomplete_shard_fetch( + last_completed_height, shard_start_pos, + tree_size_before_completing, tree_size_after_completing, + ); + info!( + "Extending tree past shard {} to chain tip (heights {} to {}, skip {} actions)", + last_completed_shard, plan.fetch_start_height, lwd_tip_height, plan.actions_to_skip, + ); + + let chunk_size = 10000u64; + let mut current_pos = shard_start_pos; + let mut current_height = plan.fetch_start_height; + let mut global_action_counter = 0u64; + while current_height <= lwd_tip_height { + let end = std::cmp::min(current_height + chunk_size - 1, lwd_tip_height); + let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + + for (_block_height, txs) in &blocks { + for (_tx_idx, cmxs) in txs { + for cmx_bytes in cmxs.iter() { + if global_action_counter < plan.actions_to_skip { + global_action_counter += 1; + continue; + } + global_action_counter += 1; + + let cmx = orchard::note::ExtractedNoteCommitment::from_bytes(cmx_bytes); + if bool::from(cmx.is_none()) { continue; } + let leaf = MerkleHashOrchard::from_cmx(&cmx.unwrap()); + // Ephemeral: we never need to spend these — they're frontier-only, + // present so the locally-reconstructed root reflects the chain tip. + tree.append(leaf, Retention::Ephemeral) + .context(format!("Failed to append frontier leaf at pos {}", current_pos))?; + current_pos += 1; + } + } + } + current_height = end + 1; + } + info!("Frontier extension done: tree size now {}", current_pos); + } + // Reconstruct notes let mut orchard_notes: Vec = Vec::new(); for (i, spendable) in notes.iter().enumerate() { @@ -1939,6 +2012,25 @@ mod tests { MerkleHashOrchard::from_bytes(&buf).unwrap() } + /// ZIP-317 §3 + BundleType::DEFAULT padding. The chain counts orchard + /// actions post-padding; we must too, otherwise mempool rejects with + /// "Unpaid actions is higher than the limit" even though the orchard + /// proof verifies. Regression: production deshield broadcast for + /// (1 spend, 0.001 ZEC out, 0.0262 change) hit this with the old + /// pre-padding fee of 10000 ZAT — the chain wanted 15000. + #[test] + fn test_zip317_deshield_fee_post_padding() { + use super::zip317_deshield_fee; + // 1 real spend + 1 change → padded to 2 actions + 1 transparent = 3 logical. + assert_eq!(zip317_deshield_fee(1), 15_000); + // 2 real spends + 1 change → max(2, max(2,1)) = 2 actions + 1 transparent = 3. + assert_eq!(zip317_deshield_fee(2), 15_000); + // 3 real spends + 1 change → 3 actions + 1 transparent = 4 logical. + assert_eq!(zip317_deshield_fee(3), 20_000); + // 5 real spends + 1 change → 5 actions + 1 transparent = 6 logical. + assert_eq!(zip317_deshield_fee(5), 30_000); + } + // ══════════════════════════════════════════════════════════════════════ // 1. Anchor Correctness — tip checkpoint vs mid-block checkpoint // ══════════════════════════════════════════════════════════════════════ @@ -2489,4 +2581,575 @@ mod tests { assert_eq!(len, 512, "Should truncate to 512"); assert!(buf.iter().all(|&b| b == b'B')); } + + // ══════════════════════════════════════════════════════════════════════ + // 4. Witness re-derivation — proves witness_at_checkpoint_id returns a + // path that actually reconstructs the tree's root when applied to its + // leaf. The chain's Orchard verifier checks this same invariant; if + // our tests assert it, we'll catch "could not validate orchard proof" + // failures locally instead of after a device confirm + Halo2 prove + + // broadcast round-trip. + // + // Existing tests only assert that witness_at_checkpoint_id returns + // `Some(_)` — that's necessary but not sufficient. A path can exist + // but be wrong. + // ══════════════════════════════════════════════════════════════════════ + + /// Helper: extract a witness for `pos` against `ckpt`, then verify that + /// applying the path to the leaf at that position re-computes the tree's + /// root at the same checkpoint. Panics with a descriptive message if the + /// witness exists but is wrong (the dangerous case the original tests + /// missed). Generic over tree dimensions so tests can use small trees + /// (`<_, 8, 4>` = 16 leaves/shard) for fast `cargo test` runs while + /// still exercising the same code paths as production (`<_, 32, 16>`). + fn assert_witness_recomputes_root( + tree: &mut ShardTree, + pos: u64, + leaf: MerkleHashOrchard, + ckpt: u32, + ctx: &str, + ) where + S: shardtree::store::ShardStore, + S::Error: std::fmt::Debug, + { + let position = incrementalmerkletree::Position::from(pos); + let path = tree + .witness_at_checkpoint_id(position, &ckpt) + .unwrap_or_else(|e| panic!("[{}] witness query at pos {} failed: {:?}", ctx, pos, e)) + .unwrap_or_else(|| panic!("[{}] no witness for pos {}", ctx, pos)); + let computed = path.root(leaf); + let expected = tree + .root_at_checkpoint_id(&ckpt) + .unwrap() + .unwrap_or_else(|| panic!("[{}] tree root unavailable at ckpt {}", ctx, ckpt)); + assert_eq!( + computed.to_bytes(), + expected.to_bytes(), + "[{}] witness for pos {} does NOT recompute tree root — \ + this is the silent failure mode the chain reports as 'could not validate orchard proof'", + ctx, + pos, + ); + } + + // Tests below use ShardTree<_, 8, 4> (depth 8, shard height 4 = 16 leaves + // per shard, 256 leaves total max) — same approach as the existing tree + // tests in this file. Production uses <_, 32, 16> (65k leaves/shard) but + // the witness invariant we're checking is identical at any depth, and + // shrinking dimensions takes `cargo test` from minutes to milliseconds. + + /// Sanity: witness for a marked leaf in a tree of all-appended leaves + /// must verify against the tree's root. + #[test] + fn test_witness_recomputes_root_pure_append() { + let n_leaves = 50u64; + let note_pos = 20u64; + let mut tree: ShardTree, 8, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for i in 0..n_leaves { + let retention = if i == note_pos { Retention::Marked } else { Retention::Ephemeral }; + tree.append(test_leaf(i), retention).unwrap(); + } + let ckpt = u32::MAX; + tree.checkpoint(ckpt).unwrap(); + assert_witness_recomputes_root(&mut tree, note_pos, test_leaf(note_pos), ckpt, "pure_append"); + } + + /// Mirrors the deshield builder's tree shape: insert N-1 completed shard + /// roots, walk shard N from leaves marking the note, no frontier. This + /// is the case where notes live in the latest incomplete shard. + #[test] + fn test_witness_recomputes_root_incomplete_shard_with_marked_note() { + use incrementalmerkletree::{Address, Position}; + + let shard_size: u64 = 1 << 4; // 16 + let n_complete_shards = 3u64; + let leaves_in_incomplete = 10u64; + let note_pos = n_complete_shards * shard_size + 5; // mid-incomplete + + // Insert completed-shard roots + let mut tree: ShardTree, 8, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for s in 0..n_complete_shards { + let mut sub: ShardTree, 4, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for j in 0..shard_size { + sub.append(test_leaf(s * shard_size + j), Retention::Ephemeral).unwrap(); + } + sub.checkpoint(0u32).unwrap(); + let root = sub.root_at_checkpoint_id(&0u32).unwrap().unwrap(); + let addr = Address::above_position(4.into(), Position::from(s * shard_size)); + tree.insert(addr, root).unwrap(); + } + + // Walk shard N's leaves, marking the note when we hit it + let incomplete_start = n_complete_shards * shard_size; + for i in 0..leaves_in_incomplete { + let pos = incomplete_start + i; + let retention = if pos == note_pos { Retention::Marked } else { Retention::Ephemeral }; + tree.append(test_leaf(pos), retention).unwrap(); + } + + let ckpt = u32::MAX; + tree.checkpoint(ckpt).unwrap(); + assert_witness_recomputes_root(&mut tree, note_pos, test_leaf(note_pos), ckpt, "incomplete_shard"); + } + + /// User's exact case (deshield Orchard → transparent): + /// - Notes in shards we walk fully (Marked) + /// - Plus an Ephemeral frontier extension past the last note-bearing + /// shard up to chain tip + /// + /// Asserts: + /// 1. Tree root after extension equals what a single all-append walk + /// would produce (anchor sanity) + /// 2. The marked note's witness recomputes that root + /// + /// If (1) passes but (2) fails, the bug is in how ShardTree handles + /// witness extraction for marked notes when an Ephemeral frontier sits + /// above them. That's exactly the failure pattern we hit in production. + #[test] + fn test_witness_recomputes_root_after_frontier_extension() { + use incrementalmerkletree::{Address, Position}; + + let shard_size: u64 = 1 << 4; // 16 + let n_complete_shards = 2u64; + let leaves_in_walked_shard = shard_size; // shard 2 is also "complete" but contains our note + let frontier_extension = 12u64; + let note_pos = 2 * shard_size + 7; + + // Reference: build everything via plain append + let mut ref_tree: ShardTree, 8, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + let total = n_complete_shards * shard_size + leaves_in_walked_shard + frontier_extension; + for i in 0..total { + let retention = if i == note_pos { Retention::Marked } else { Retention::Ephemeral }; + ref_tree.append(test_leaf(i), retention).unwrap(); + } + let ref_ckpt = u32::MAX; + ref_tree.checkpoint(ref_ckpt).unwrap(); + let ref_root = ref_tree.root_at_checkpoint_id(&ref_ckpt).unwrap().unwrap(); + + // Production: insert completed-shard roots, walk note-bearing shard, + // then ephemeral frontier extension (mirrors build_deshield_pczt). + let mut tree: ShardTree, 8, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + + for s in 0..n_complete_shards { + let mut sub: ShardTree, 4, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for j in 0..shard_size { + sub.append(test_leaf(s * shard_size + j), Retention::Ephemeral).unwrap(); + } + sub.checkpoint(0u32).unwrap(); + let root = sub.root_at_checkpoint_id(&0u32).unwrap().unwrap(); + let addr = Address::above_position(4.into(), Position::from(s * shard_size)); + tree.insert(addr, root).unwrap(); + } + + let walked_start = n_complete_shards * shard_size; + for i in 0..leaves_in_walked_shard { + let pos = walked_start + i; + let retention = if pos == note_pos { Retention::Marked } else { Retention::Ephemeral }; + tree.append(test_leaf(pos), retention).unwrap(); + } + + let frontier_start = walked_start + leaves_in_walked_shard; + for i in 0..frontier_extension { + tree.append(test_leaf(frontier_start + i), Retention::Ephemeral).unwrap(); + } + + let ckpt = u32::MAX; + tree.checkpoint(ckpt).unwrap(); + let prod_root = tree.root_at_checkpoint_id(&ckpt).unwrap().unwrap(); + + // Invariant 1: production tree root must match the reference (anchor sanity) + assert_eq!( + prod_root.to_bytes(), + ref_root.to_bytes(), + "Tree built via insert+walk+frontier-extension must match all-append reference", + ); + + // Invariant 2: witness for the marked note must recompute that root + assert_witness_recomputes_root(&mut tree, note_pos, test_leaf(note_pos), ckpt, "frontier_extension"); + } + + /// Two marked notes — one in a shard we walk (well-confirmed), + /// one in the frontier-extended region (recently mined). The frontier + /// extension uses Retention::Ephemeral; this test verifies that the + /// older marked note's witness still recomputes the root despite the + /// ephemeral leaves above it. + #[test] + fn test_witness_recomputes_root_two_marked_notes_split() { + use incrementalmerkletree::{Address, Position}; + + let shard_size: u64 = 1 << 4; // 16 + let n_complete_shards = 2u64; + let walked_leaves = shard_size; + let frontier_extension = 8u64; + let walked_note_pos = n_complete_shards * shard_size + 6; + + let mut tree: ShardTree, 8, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + + for s in 0..n_complete_shards { + let mut sub: ShardTree, 4, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for j in 0..shard_size { + sub.append(test_leaf(s * shard_size + j), Retention::Ephemeral).unwrap(); + } + sub.checkpoint(0u32).unwrap(); + let root = sub.root_at_checkpoint_id(&0u32).unwrap().unwrap(); + let addr = Address::above_position(4.into(), Position::from(s * shard_size)); + tree.insert(addr, root).unwrap(); + } + + let walked_start = n_complete_shards * shard_size; + for i in 0..walked_leaves { + let pos = walked_start + i; + let retention = if pos == walked_note_pos { Retention::Marked } else { Retention::Ephemeral }; + tree.append(test_leaf(pos), retention).unwrap(); + } + + let frontier_start = walked_start + walked_leaves; + for i in 0..frontier_extension { + tree.append(test_leaf(frontier_start + i), Retention::Ephemeral).unwrap(); + } + + let ckpt = u32::MAX; + tree.checkpoint(ckpt).unwrap(); + + assert_witness_recomputes_root( + &mut tree, walked_note_pos, test_leaf(walked_note_pos), ckpt, + "split: walked note", + ); + } +} + +// ── v5 transaction round-trip tests ─────────────────────────────────────── +// +// Production private sends and shield txs broadcast successfully, but deshield +// (orchard spends → transparent output) fails at broadcast with "could not +// validate orchard proof" — even though the per-action redpallas signature +// and the orchard binding signature both verify locally before serialization. +// +// One way that pattern can occur: the bytes we hand to lightwalletd parse to a +// transaction whose canonical txid / digests differ from what we computed +// internally. That would produce a bundle whose embedded Halo2 public inputs +// disagree with what the chain extracts from the wire, so consensus rejects +// the proof. +// +// These tests round-trip our v5 serializers through `zcash_primitives`' +// canonical reader and check (a) the bytes parse, (b) per-action Orchard +// fields survive the round-trip, and (c) our computed txid matches what +// `Transaction::read` reconstructs. +// +// `roundtrip_v5_shielded_only` is a canary — production private sends use this +// path successfully, so it MUST pass. If it doesn't, the test infra (synthetic +// bundle, fixture bytes, comparison helpers) is what's broken. +// +// `roundtrip_v5_hybrid_shield` exercises the `inputs=[real], outputs=[]` +// direction that production shield uses successfully — also expected to pass. +// +// `roundtrip_v5_hybrid_deshield` exercises `inputs=[], outputs=[real]` — the +// unique combination only deshield uses. If a serializer or zip244 digest bug +// is direction-specific, this is where it shows up. + +#[cfg(test)] +mod roundtrip_v5_tests { + use super::{serialize_v5_hybrid_tx, serialize_v5_shielded_tx}; + use crate::zip244::{ + self, Zip244Digests, EMPTY_SAPLING_DIGEST, EMPTY_TRANSPARENT_DIGEST, + TransparentInput, TransparentOutput, + }; + use nonempty::NonEmpty; + use orchard::{ + bundle::{Authorized, Flags}, + note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext}, + primitives::redpallas, + value::ValueCommitment, + Action, Anchor, Proof, + }; + use zcash_primitives::transaction::Transaction; + // zcash_primitives 0.19 pins zcash_protocol 0.4; its `Transaction::read` + // signature wants that exact BranchId. Our crate also depends on + // zcash_protocol 0.7 (used elsewhere). Pull the 0.4 alias here. + use zcash_protocol_v04::consensus::BranchId; + + /// NU5 consensus branch id. Picked because deshield txs ship under NU5+ + /// rules and `Transaction::read` for v5 ignores the branch_id parameter + /// anyway — what matters is matching the value encoded in the tx header. + const NU5_BRANCH_ID: u32 = 0xc2d6_d0b4; + + // ── Test vector bytes ──────────────────────────────────────────────── + // Pulled from `orchard-0.12.0/src/test_vectors/note_encryption.rs` TV[0]. + // Real, on-curve Pallas points known to round-trip through the canonical + // reader. We only need on-curve-ness — the values themselves are + // arbitrary for serialization tests. + + const TV_CV_NET: [u8; 32] = [ + 0xdd, 0xba, 0x24, 0xf3, 0x9f, 0x70, 0x8e, 0xd7, 0xa7, 0x48, 0x57, 0x13, 0x71, 0x11, + 0x42, 0xc2, 0x38, 0x51, 0x38, 0x15, 0x30, 0x2d, 0xf0, 0xf4, 0x83, 0x04, 0x21, 0xa6, + 0xc1, 0x3e, 0x71, 0x01, + ]; + const TV_NF_OLD: [u8; 32] = [ + 0xc5, 0x96, 0xfb, 0xd3, 0x2e, 0xbb, 0xcb, 0xad, 0xae, 0x60, 0xd2, 0x85, 0xc7, 0xd7, + 0x5f, 0xa8, 0x36, 0xf9, 0xd2, 0xfa, 0x86, 0x10, 0x0a, 0xb8, 0x58, 0xea, 0x2d, 0xe1, + 0xf1, 0x1c, 0x83, 0x06, + ]; + const TV_CMX: [u8; 32] = [ + 0xa5, 0x70, 0x6f, 0x3d, 0x1b, 0x68, 0x8e, 0x9d, 0xc6, 0x34, 0xee, 0xe4, 0xe6, 0x5b, + 0x02, 0x8a, 0x43, 0xee, 0xae, 0xd2, 0x43, 0x5b, 0xea, 0x2a, 0xe3, 0xd5, 0x16, 0x05, + 0x75, 0xc1, 0x1a, 0x3b, + ]; + const TV_EPK: [u8; 32] = [ + 0xad, 0xdb, 0x47, 0xb6, 0xac, 0x5d, 0xfc, 0x16, 0x55, 0x89, 0x23, 0xd3, 0xa8, 0xf3, + 0x76, 0x09, 0x5c, 0x69, 0x5c, 0x04, 0x7c, 0x4e, 0x32, 0x66, 0xae, 0x67, 0x69, 0x87, + 0xf7, 0xe3, 0x13, 0x81, + ]; + const TV_C_OUT: [u8; 80] = [ + 0xcb, 0xdf, 0x68, 0xa5, 0x7f, 0xb4, 0xa4, 0x6f, 0x34, 0x60, 0xff, 0x22, 0x7b, 0xc6, + 0x18, 0xda, 0xe1, 0x12, 0x29, 0x45, 0xb3, 0x80, 0xc7, 0xe5, 0x49, 0xcf, 0x4a, 0x6e, + 0x8b, 0xf3, 0x75, 0x49, 0xba, 0xe1, 0x89, 0x1f, 0xd8, 0xd1, 0xa4, 0x94, 0x4f, 0xdf, + 0x41, 0x0f, 0x07, 0x02, 0xed, 0xa5, 0x44, 0x2f, 0x0e, 0xa0, 0x1a, 0x5d, 0xf0, 0x12, + 0xa0, 0xae, 0x4d, 0x84, 0xed, 0x79, 0x80, 0x33, 0x28, 0xbd, 0x1f, 0xd5, 0xfa, 0xc7, + 0x19, 0x21, 0x6a, 0x77, 0x6d, 0xe6, 0x4f, 0xd1, 0x67, 0xdb, + ]; + + /// 580-byte enc_ciphertext from TV[0]. Initialized at runtime to keep the + /// const table small; the orchard reader doesn't validate its contents. + fn tv_c_enc() -> [u8; 580] { + let raw: &[u8] = &[ + 0x1a, 0x9a, 0xdb, 0x14, 0x24, 0x98, 0xe3, 0xdc, 0xc7, 0x6f, 0xed, 0x77, 0x86, 0x14, + 0xdd, 0x31, 0x6c, 0x02, 0xfb, 0xb8, 0xba, 0x92, 0x44, 0xae, 0x4c, 0x2e, 0x32, 0xa0, + 0x7d, 0xae, 0xec, 0xa4, 0x12, 0x26, 0xb9, 0x8b, 0xfe, 0x74, 0xf9, 0xfc, 0xb2, 0x28, + 0xcf, 0xc1, 0x00, 0xf3, 0x18, 0x0f, 0x57, 0x75, 0xec, 0xe3, 0x8b, 0xe7, 0xed, 0x45, + 0xd9, 0x40, 0x21, 0xf4, 0x40, 0x1b, 0x2a, 0x4d, 0x75, 0x82, 0xb4, 0x28, 0xd4, 0x9e, + 0xc7, 0xf5, 0xb5, 0xa4, 0x98, 0x97, 0x3e, 0x60, 0xe3, 0x8e, 0x74, 0xf5, 0xc3, 0xe5, + 0x77, 0x82, 0x7c, 0x38, 0x28, 0x57, 0xd8, 0x16, 0x6b, 0x54, 0xe6, 0x4f, 0x66, 0xef, + 0x5c, 0x7e, 0x8c, 0x9b, 0xaa, 0x2a, 0x3f, 0xa9, 0xe3, 0x7d, 0x08, 0x77, 0x17, 0xd5, + 0xe9, 0x6b, 0xc2, 0xf7, 0x3d, 0x03, 0x14, 0x50, 0xdc, 0x24, 0x32, 0xba, 0x49, 0xd8, + 0xb7, 0x4d, 0xb2, 0x13, 0x09, 0x9e, 0xa9, 0xba, 0x04, 0xeb, 0x63, 0xb6, 0x57, 0x4d, + 0x46, 0xc0, 0x3c, 0xe7, 0x90, 0x0d, 0x4a, 0xc4, 0xbb, 0x18, 0x8e, 0xe9, 0x03, 0x0d, + 0x7f, 0x69, 0xc8, 0x95, 0xa9, 0x4f, 0xc1, 0x82, 0xf2, 0x25, 0xa9, 0x4f, 0x0c, 0xde, + 0x1b, 0x49, 0x88, 0x68, 0x71, 0xa3, 0x76, 0x34, 0x1e, 0xa9, 0x41, 0x71, 0xbe, 0xfd, + 0x95, 0xa8, 0x30, 0xfa, 0x18, 0x40, 0x70, 0x97, 0xdc, 0xa5, 0x11, 0x02, 0x54, 0x63, + 0xd4, 0x37, 0xe9, 0x69, 0x5c, 0xaa, 0x07, 0x9a, 0x2f, 0x68, 0xcd, 0xc7, 0xf2, 0xc1, + 0x32, 0x67, 0xbf, 0xf4, 0x19, 0x51, 0x37, 0xfa, 0x89, 0x53, 0x25, 0x2a, 0x81, 0xb2, + 0xaf, 0xa1, 0x58, 0x2b, 0x9b, 0xfb, 0x4a, 0xc9, 0x60, 0x37, 0xed, 0x29, 0x91, 0xd3, + 0xcb, 0xc7, 0xd5, 0x4a, 0xff, 0x6e, 0x62, 0x1b, 0x06, 0xa7, 0xb2, 0xb9, 0xca, 0xf2, + 0x95, 0x5e, 0xfa, 0xf4, 0xea, 0x8e, 0xfc, 0xfd, 0x02, 0x3a, 0x3c, 0x17, 0x48, 0xdf, + 0x3c, 0xbd, 0x43, 0xe0, 0xb9, 0xa8, 0xb0, 0x94, 0x56, 0x88, 0xd5, 0x20, 0x56, 0xc1, + 0xd1, 0x6e, 0xea, 0x37, 0xe7, 0x98, 0xba, 0x31, 0xdc, 0x3e, 0x5d, 0x49, 0x52, 0xbd, + 0x51, 0xec, 0x76, 0x9d, 0x57, 0x88, 0xb6, 0xe3, 0x5f, 0xe9, 0x04, 0x2b, 0x95, 0xd4, + 0xd2, 0x17, 0x81, 0x40, 0x0e, 0xaf, 0xf5, 0x86, 0x16, 0xad, 0x56, 0x27, 0x96, 0x63, + 0x6a, 0x50, 0xb8, 0xed, 0x6c, 0x7f, 0x98, 0x1d, 0xc7, 0xba, 0x81, 0x4e, 0xff, 0x15, + 0x2c, 0xb2, 0x28, 0xa2, 0xea, 0xd2, 0xf8, 0x32, 0x66, 0x2f, 0xa4, 0xa4, 0xa5, 0x07, + 0x97, 0xb0, 0xf8, 0x5b, 0x62, 0xd0, 0x8b, 0x1d, 0xd2, 0xd8, 0xe4, 0x3b, 0x4a, 0x5b, + 0xfb, 0xb1, 0x59, 0xed, 0x57, 0x8e, 0xf7, 0x47, 0x5d, 0xe0, 0xad, 0xa1, 0x3e, 0x17, + 0xad, 0x87, 0xcc, 0x23, 0x05, 0x67, 0x2b, 0xcc, 0x55, 0xa8, 0x88, 0x13, 0x17, 0xfd, + 0xc1, 0xbf, 0xc4, 0x59, 0xb6, 0x8b, 0x2d, 0xf7, 0x0c, 0xad, 0x37, 0x70, 0xed, 0x0f, + 0xd0, 0x2d, 0x64, 0xb9, 0x6f, 0x2b, 0xbf, 0x6f, 0x8f, 0x63, 0x2e, 0x86, 0x6c, 0xa5, + 0xd1, 0x96, 0xd2, 0x48, 0xad, 0x05, 0xc3, 0xde, 0x64, 0x41, 0x48, 0xa8, 0x0b, 0x51, + 0xad, 0xa9, 0x5b, 0xd0, 0x8d, 0x73, 0xcd, 0xbb, 0x45, 0x26, 0x4f, 0x3b, 0xd1, 0x13, + 0x83, 0x5b, 0x46, 0xf9, 0xbe, 0x7b, 0x6d, 0x23, 0xa4, 0x3b, 0xdd, 0xfe, 0x1e, 0x74, + 0x08, 0xc9, 0x70, 0x31, 0xe1, 0xa8, 0x21, 0x4b, 0xab, 0x46, 0x39, 0x10, 0x44, 0xb7, + 0x00, 0xd3, 0x8f, 0x51, 0x92, 0xc5, 0x7f, 0xe6, 0xf8, 0x71, 0x59, 0xb5, 0x55, 0x12, + 0x09, 0x4e, 0x29, 0xd2, 0xce, 0xba, 0xb8, 0x68, 0xc8, 0xf1, 0xad, 0xba, 0xd5, 0x70, + 0x77, 0xcb, 0xeb, 0x5e, 0x69, 0x65, 0x85, 0x82, 0xbf, 0x98, 0xd1, 0x9d, 0x64, 0xf4, + 0x4b, 0x0d, 0x50, 0xc7, 0xe2, 0x20, 0x9a, 0xb3, 0xfc, 0x56, 0xb4, 0xf4, 0x09, 0x12, + 0x3a, 0xae, 0xb0, 0x26, 0x3a, 0x22, 0x45, 0x1b, 0xc1, 0x4e, 0xd7, 0x56, 0xd0, 0x48, + 0x38, 0x5a, 0xed, 0xbb, 0x86, 0xa8, 0x46, 0x77, 0xbb, 0x2d, 0x21, 0xc5, 0x2c, 0xc9, + 0x49, 0x41, 0x47, 0xbf, 0x0f, 0xb1, 0x02, 0x74, 0x52, 0x82, 0x99, 0x09, 0x09, 0x72, + 0x62, 0x28, 0x18, 0x6e, 0x02, 0xc8, + ]; + let mut buf = [0u8; 580]; + buf.copy_from_slice(raw); + buf + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// One synthetic Orchard action with on-curve Pallas points + arbitrary sig. + fn synthetic_action() -> Action> { + let cv_net = ValueCommitment::from_bytes(&TV_CV_NET).unwrap(); + let nf = Nullifier::from_bytes(&TV_NF_OLD).unwrap(); + // `rk` is a randomized SpendAuth verification key — same compressed-Pallas + // encoding as a Nullifier, so a valid nf byte string is also valid as rk. + let rk = redpallas::VerificationKey::::try_from(TV_NF_OLD) + .expect("TV nf bytes must round-trip as a redpallas SpendAuth VerificationKey"); + let cmx = ExtractedNoteCommitment::from_bytes(&TV_CMX).unwrap(); + let encrypted_note = TransmittedNoteCiphertext { + epk_bytes: TV_EPK, + enc_ciphertext: tv_c_enc(), + out_ciphertext: TV_C_OUT, + }; + let spend_auth_sig: redpallas::Signature = [0xab; 64].into(); + Action::from_parts(nf, rk, cmx, encrypted_note, cv_net, spend_auth_sig) + } + + fn synthetic_bundle(n_actions: usize, value_balance: i64) + -> orchard::Bundle + { + assert!(n_actions >= 1); + let actions: Vec<_> = (0..n_actions).map(|_| synthetic_action()).collect(); + let actions_ne = NonEmpty::from_vec(actions).unwrap(); + let flags = Flags::from_byte(0x03).unwrap(); + let anchor = Anchor::from_bytes(TV_CMX).unwrap(); + let proof = Proof::new(vec![0u8; 1500]); + let binding_sig: redpallas::Signature = [0xcd; 64].into(); + let auth = Authorized::from_parts(proof, binding_sig); + orchard::Bundle::from_parts(actions_ne, flags, value_balance, anchor, auth) + } + + /// Recompute the txid the way `finalize_pczt` (shielded-only) does. + fn our_txid_shielded(bundle: &orchard::Bundle, branch_id: u32) -> [u8; 32] { + let digests = Zip244Digests { + header_digest: zip244::digest_header(branch_id, 0, 0), + transparent_digest: EMPTY_TRANSPARENT_DIGEST, + sapling_digest: EMPTY_SAPLING_DIGEST, + orchard_digest: zip244::digest_orchard(bundle), + }; + zip244::compute_sighash(&digests, branch_id) + } + + /// Recompute the txid the way `finalize_shield_pczt` / `finalize_deshield_pczt` + /// do (hybrid path with non-empty transparent component). + fn our_txid_hybrid( + bundle: &orchard::Bundle, + inputs: &[TransparentInput], + outputs: &[TransparentOutput], + branch_id: u32, + ) -> [u8; 32] { + let digests = Zip244Digests { + header_digest: zip244::digest_header(branch_id, 0, 0), + transparent_digest: zip244::digest_transparent_txid(inputs, outputs), + sapling_digest: EMPTY_SAPLING_DIGEST, + orchard_digest: zip244::digest_orchard(bundle), + }; + zip244::compute_sighash(&digests, branch_id) + } + + // Note on cross-version comparison: `zcash_primitives 0.19` pins + // `orchard 0.10`, while we directly depend on `orchard 0.12`. The two + // `orchard::Bundle` types are distinct in the type system (different crate + // versions), so we can't pass our 0.12 bundle to a comparator that takes + // the 0.10 bundle the parser returns. Fortunately, txid = BLAKE2b(header + // || transparent_digest || sapling_digest || orchard_digest), so any + // per-action byte divergence shows up as an `orchard_digest` divergence + // → txid mismatch — making the txid assertion sufficient. If we ever need + // field-level diagnostics on failure, collect `cv_net().to_bytes()` etc. + // into `Vec<[u8; 32]>` on each side (raw bytes have no version skew). + + fn p2pkh_script(hash160: [u8; 20]) -> Vec { + let mut s = Vec::with_capacity(25); + s.extend_from_slice(&[0x76, 0xa9, 0x14]); // OP_DUP OP_HASH160 PUSH20 + s.extend_from_slice(&hash160); + s.extend_from_slice(&[0x88, 0xac]); // OP_EQUALVERIFY OP_CHECKSIG + s + } + + // ── Tests ──────────────────────────────────────────────────────────── + + /// Canary — the all-orchard path that production private sends use. + /// Production confirms these on-chain, so this MUST pass. + #[test] + fn roundtrip_v5_shielded_only() { + let bundle = synthetic_bundle(1, 0); + let tx_bytes = serialize_v5_shielded_tx(&bundle, NU5_BRANCH_ID).unwrap(); + + let parsed = Transaction::read(&tx_bytes[..], BranchId::Nu5) + .expect("canonical reader must accept our v5 shielded tx bytes"); + assert!(parsed.orchard_bundle().is_some(), "orchard bundle present in parsed tx"); + assert_eq!( + parsed.orchard_bundle().unwrap().actions().len(), + bundle.actions().len(), + "orchard action count round-tripped", + ); + + let ours = our_txid_shielded(&bundle, NU5_BRANCH_ID); + assert_eq!( + *parsed.txid().as_ref(), ours, + "shielded txid mismatch — our zip244 digests disagree with the canonical reference" + ); + } + + /// Hybrid with transparent INPUTS and no outputs — the direction that + /// production shield uses successfully. Expected to pass. + #[test] + fn roundtrip_v5_hybrid_shield() { + let bundle = synthetic_bundle(1, 100_000); + let inputs = vec![TransparentInput { + prevout_hash: [0x11; 32], + prevout_index: 0, + value: 105_000, + script_pubkey: p2pkh_script([0xde; 20]), + sequence: 0xffff_ffff, + }]; + // 71-byte synthetic DER signature payload (the +1 SIGHASH_ALL byte is + // appended by serialize_v5_hybrid_tx itself). + let synth_sig = vec![0u8; 71]; + let synth_pubkey = [0x02u8; 33]; + let tx_bytes = serialize_v5_hybrid_tx( + &bundle, &inputs, &[], &[synth_sig], NU5_BRANCH_ID, Some(&synth_pubkey), + ).unwrap(); + + let parsed = Transaction::read(&tx_bytes[..], BranchId::Nu5) + .expect("canonical reader must accept our v5 hybrid (shield) bytes"); + let parsed_transparent = parsed.transparent_bundle() + .expect("transparent bundle present"); + assert_eq!(parsed_transparent.vin.len(), 1, "exactly one transparent input"); + assert_eq!(parsed_transparent.vout.len(), 0, "no transparent outputs"); + assert_eq!( + parsed.orchard_bundle().expect("orchard bundle present").actions().len(), + bundle.actions().len(), + "orchard action count round-tripped", + ); + + let ours = our_txid_hybrid(&bundle, &inputs, &[], NU5_BRANCH_ID); + assert_eq!( + *parsed.txid().as_ref(), ours, + "shield txid mismatch — txid digest differs from canonical even though \ + production shield works on-chain (test infra bug?)" + ); + } + + /// Hybrid with no transparent INPUTS and one transparent OUTPUT — the + /// unique combination only deshield uses. Production fails at broadcast + /// with "could not validate orchard proof" on this shape. If the bug is + /// in our serializer or zip244 digest computation, this test will pin it + /// down at field level. + #[test] + fn roundtrip_v5_hybrid_deshield() { + let bundle = synthetic_bundle(1, -190_000); + let outputs = vec![TransparentOutput { + value: 185_000, + script_pubkey: p2pkh_script([0xbe; 20]), + }]; + let tx_bytes = serialize_v5_hybrid_tx( + &bundle, &[], &outputs, &[], NU5_BRANCH_ID, None, + ).unwrap(); + + let parsed = Transaction::read(&tx_bytes[..], BranchId::Nu5) + .expect("canonical reader must accept our v5 hybrid (deshield) bytes"); + let parsed_transparent = parsed.transparent_bundle() + .expect("transparent bundle present"); + assert_eq!(parsed_transparent.vin.len(), 0, "no transparent inputs"); + assert_eq!(parsed_transparent.vout.len(), 1, "exactly one transparent output"); + let parsed_out = &parsed_transparent.vout[0]; + assert_eq!(u64::from(parsed_out.value), outputs[0].value, "vout value"); + assert_eq!(parsed_out.script_pubkey.0, outputs[0].script_pubkey, "vout script"); + assert_eq!( + parsed.orchard_bundle().expect("orchard bundle present").actions().len(), + bundle.actions().len(), + "orchard action count round-tripped", + ); + + let ours = our_txid_hybrid(&bundle, &[], &outputs, NU5_BRANCH_ID); + assert_eq!( + *parsed.txid().as_ref(), ours, + "deshield txid mismatch — our serializer or zip244 digest disagrees \ + with the canonical reference; this is the bug deshield broadcasts hit" + ); + } } diff --git a/projects/keepkey-vault/zcash-cli/src/wallet_db.rs b/projects/keepkey-vault/zcash-cli/src/wallet_db.rs index 5f23c22d..0a6ec898 100644 --- a/projects/keepkey-vault/zcash-cli/src/wallet_db.rs +++ b/projects/keepkey-vault/zcash-cli/src/wallet_db.rs @@ -234,13 +234,26 @@ impl WalletDb { } /// Get all unspent notes that can be used for spending. - pub fn get_spendable_notes(&self) -> Result> { + /// + /// `max_block_height` is the highest block height a note may have been mined + /// in to be eligible. Pass `tip - MIN_CONFIRMATIONS` to skip recently-mined + /// notes, or `None` for no filtering. Without this filter, a note received + /// in the last few blocks can fail the chain's Halo2 proof verification on + /// broadcast — its position in the local tree may differ from the chain's + /// after a small reorg, or lightwalletd's tree state may lag the cmx scan. + /// Industry default is 10 confirmations (matches zcashd / ywallet). + pub fn get_spendable_notes(&self, max_block_height: Option) -> Result> { + // Single statement form using a sentinel: when max is None, pass i64::MAX + // as the bound so the WHERE clause matches every row. Avoids the dance + // of building two different prepared statements with different param + // arity. + let max_h = max_block_height.map(|h| h as i64).unwrap_or(i64::MAX); let mut stmt = self.conn.prepare( "SELECT id, value, recipient, rho, rseed, cmx, nullifier, block_height, tx_index, action_index, position - FROM notes WHERE is_spent = 0 ORDER BY value DESC" + FROM notes WHERE is_spent = 0 AND block_height <= ?1 ORDER BY value DESC" )?; - let notes = stmt.query_map([], |row| { + let notes = stmt.query_map(params![max_h], |row| { let rho_blob: Vec = row.get(3)?; let rseed_blob: Vec = row.get(4)?; let cmx_blob: Vec = row.get(5)?; diff --git a/projects/keepkey-vault/zcash-cli/src/zip244.rs b/projects/keepkey-vault/zcash-cli/src/zip244.rs index ccf01734..d8a6ae73 100644 --- a/projects/keepkey-vault/zcash-cli/src/zip244.rs +++ b/projects/keepkey-vault/zcash-cli/src/zip244.rs @@ -242,25 +242,23 @@ pub fn digest_transparent_outputs(outputs: &[TransparentOutput]) -> [u8; 32] { blake2b_256(b"ZTxIdOutputsHash", &data) } -/// Compute the full transparent digest for txid computation (ZIP-244 §4.5). -/// This is the NON-sig version: prevouts || amounts || scripts || sequences || outputs. -/// No hash_type byte, no txin_sig_digest. Used in the txid (NOT in sighash). +/// Compute the transparent digest for txid computation (ZIP-244 §4.5 / T.1). +/// +/// Per spec: T.1 = BLAKE2b-256("ZTxIdTranspaHash", T.2a (prevouts) || T.2b +/// (sequence) || T.2c (outputs)). The amounts/scripts sub-digests are +/// sighash-only (§4.10) and MUST NOT appear here, despite both digests sharing +/// the "ZTxIdTranspaHash" personalization. pub fn digest_transparent_txid(inputs: &[TransparentInput], outputs: &[TransparentOutput]) -> [u8; 32] { if inputs.is_empty() && outputs.is_empty() { return EMPTY_TRANSPARENT_DIGEST; } let prevouts = digest_transparent_prevouts(inputs); - let amounts = digest_transparent_amounts(inputs); - let scripts = digest_transparent_scripts(inputs); let sequences = digest_transparent_sequence(inputs); let outputs_hash = digest_transparent_outputs(outputs); - // ZIP-244 §4.5: txid transparent_digest has NO hash_type byte - let mut data = Vec::with_capacity(32 * 5); + let mut data = Vec::with_capacity(32 * 3); data.extend_from_slice(&prevouts); - data.extend_from_slice(&amounts); - data.extend_from_slice(&scripts); data.extend_from_slice(&sequences); data.extend_from_slice(&outputs_hash); @@ -329,15 +327,23 @@ pub fn compute_transparent_sig_hash( blake2b_256(&personal, &sighash_data) } -/// Compute the transparent_sig_digest for Orchard spend authorization (ZIP-244 §4.7). +/// Compute the transparent_sig_digest for Orchard spend authorization (ZIP-244 §4.10). /// -/// For Orchard spend auth in hybrid (transparent + Orchard) transactions, the sighash -/// uses transparent_sig_digest — NOT the txid transparent_digest. Per ZIP-244 §4.6-4.7, -/// the signature digest includes hash_type (SIGHASH_ALL = 0x01) and an empty -/// txin_sig_digest, both absent from the txid transparent_digest. +/// Per ZIP-244 §4.10b: when the transaction has **no transparent inputs** (or only +/// a coinbase input), the transparent_sig_digest is identical to the +/// transparent_txid_digest (T.1 form, 3 sub-digests: prevouts || sequence || +/// outputs). Only when there is at least one non-coinbase transparent input +/// being spent does the digest take the full S.2 form (hash_type || +/// prevouts || amounts || scripts || sequences || outputs || txin_sig_digest). /// -/// When there are no transparent inputs/outputs, transparent_sig_digest equals the -/// txid transparent_digest (both are the hash of empty data). +/// Missing the `inputs.is_empty()` short-circuit was the bug behind deshield +/// (orchard → transparent) broadcasts failing with "could not validate orchard +/// proof": we'd hand the device a sighash computed via the full S.2 form, but +/// the chain re-derived sighash via the T.1 form per §4.10b, the redpallas +/// signature didn't bind to the chain's sighash, and consensus rejected the +/// proof. (Shield txs spend transparent inputs, so they correctly take the +/// full S.2 form on both sides; private sends have neither inputs nor outputs +/// and hit the EMPTY_TRANSPARENT_DIGEST short-circuit on both sides.) pub fn digest_transparent_sig_for_orchard( inputs: &[TransparentInput], outputs: &[TransparentOutput], @@ -346,6 +352,11 @@ pub fn digest_transparent_sig_for_orchard( return EMPTY_TRANSPARENT_DIGEST; } + // ZIP-244 §4.10b: empty vin → use the txid-form digest unchanged. + if inputs.is_empty() { + return digest_transparent_txid(inputs, outputs); + } + let hash_type: u8 = 0x01; // SIGHASH_ALL (ZIP-244 convention for Orchard) let prevouts = digest_transparent_prevouts(inputs); @@ -550,6 +561,39 @@ mod tests { assert_eq!(sig_digest, EMPTY_TRANSPARENT_DIGEST); } + /// ZIP-244 §4.10b: when there are NO transparent inputs but there ARE + /// transparent outputs (the deshield shape — Orchard spends → transparent + /// output), the transparent_sig_digest is identical to the + /// transparent_txid_digest. The full S.2 form (with hash_type, amounts, + /// scripts, txin_sig_digest) is reserved for txs that actually spend a + /// transparent input. + /// + /// Regression: missing this special case caused deshield broadcasts to + /// fail with "could not validate orchard proof" — the device signed under + /// our wrong S.2-form sighash, the chain re-derived the right T.1-form + /// sighash per §4.10b, and the redpallas spend-auth signature verification + /// failed. + #[test] + fn test_transparent_sig_digest_uses_txid_form_when_vin_empty() { + let outputs = vec![TransparentOutput { + value: 90_000, + script_pubkey: vec![ + 0x76, 0xa9, 0x14, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0x88, 0xac, + ], + }]; + + let sig_digest = digest_transparent_sig_for_orchard(&[], &outputs); + let txid_digest = digest_transparent_txid(&[], &outputs); + + assert_eq!( + sig_digest, txid_digest, + "ZIP-244 §4.10b violation: with no transparent inputs, \ + transparent_sig_digest must equal transparent_txid_digest. \ + A divergence here is exactly what makes deshield broadcasts \ + fail consensus while the bundle verifies locally." + ); + } + fn hex_to_array(s: &str) -> [u8; 32] { let bytes = hex::decode(s).unwrap(); let mut arr = [0u8; 32]; diff --git a/scripts/build-windows-production.ps1 b/scripts/build-windows-production.ps1 index 5104a20d..9d3a84d4 100644 --- a/scripts/build-windows-production.ps1 +++ b/scripts/build-windows-production.ps1 @@ -29,9 +29,21 @@ param( [switch]$SkipBuild = $false, [switch]$SkipSign = $false, - [string]$Thumbprint = "986AEBA61CF6616393E74D8CBD3A09E836213BAA", - [string]$TimestampUrl = "http://timestamp.digicert.com", - [string]$OutputDir = "release-windows" + # Cert thumbprint: CLI arg > $env:KK_SIGN_THUMBPRINT > hardcoded KEY HODLERS LLC EV cert. + # The env var lets CI / a new signer override without editing the script. + [string]$Thumbprint = $(if ($env:KK_SIGN_THUMBPRINT) { $env:KK_SIGN_THUMBPRINT } else { "986AEBA61CF6616393E74D8CBD3A09E836213BAA" }), + # Timestamp servers tried in order. Any RFC 3161 server works; ordered by + # historical reliability. First-success short-circuits; full list exhausted + # before Sign-File reports a failure. + [string[]]$TimestampUrls = @( + "http://timestamp.digicert.com", + "http://timestamp.sectigo.com", + "http://timestamp.globalsign.com/tsa/r6advanced1" + ), + [string]$OutputDir = "release-windows", + # Set to allow non-fatal sign failures (e.g. iterating on a non-signing + # machine). Default: any unexpected failure aborts the run. + [switch]$AllowSignFailures = $false ) Set-StrictMode -Version Latest @@ -81,6 +93,8 @@ 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" +$ExtModulesDir = Join-Path $ProjectDir "_build\_ext_modules" +$AppNodeModulesDir = Join-Path $BuildDir "Resources\app\node_modules" $ArtifactsDir = Join-Path $RepoRoot $OutputDir # Read version from package.json @@ -127,10 +141,53 @@ function Assert-Command { } } +# Zero out the Certificate Table (Security Directory) entry in a PE's Optional +# Header. Returns $true if an entry existed and was cleared, $false if the +# file already had no entry (no-op). +# +# Why this exists: rcedit (and some upstream Electrobun-built launcher.exe +# binaries) leave a non-zero Security Directory RVA/Size pointing at garbage +# data — the file isn't actually signed (Get-AuthenticodeSignature reports +# NotSigned), but a stale ~10 KB cert-table-shaped chunk lives inside the PE. +# signtool then refuses to sign the file with the misleading error +# `0x800700C1 / ERROR_BAD_EXE_FORMAT` because it can't safely overwrite the +# malformed cert table. `signtool remove /s` also fails (`0x00000057`) +# because there's no valid signature to strip. +# +# The fix is purely a 8-byte zero write in the PE Optional Header — the +# orphan cert blob at the end of the file is harmless (signtool overwrites +# or appends past it). No section table, no checksum, no relocation needs +# to change. +function Clear-PECertTableEntry { + param([string]$FilePath) + $bytes = [System.IO.File]::ReadAllBytes($FilePath) + if ($bytes.Length -lt 64) { return $false } + if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) { return $false } # MZ + $peOff = [BitConverter]::ToInt32($bytes, 60) + if ($peOff -lt 0 -or $peOff + 24 -ge $bytes.Length) { return $false } + if ($bytes[$peOff] -ne 0x50 -or $bytes[$peOff + 1] -ne 0x45) { return $false } # PE + $optOff = $peOff + 24 + $magic = [BitConverter]::ToUInt16($bytes, $optOff) + # NumberOfRvaAndSizes lives at +108 for PE32+, +92 for PE32 — pick the + # right offset so we land on the actual DataDirectories array. + $rvaCountOff = if ($magic -eq 0x20B) { $optOff + 108 } elseif ($magic -eq 0x10B) { $optOff + 92 } else { return $false } + if ($rvaCountOff + 4 + (5 * 8) -gt $bytes.Length) { return $false } + # Security Directory is entry index 4 in the DataDirectories array + # (Export=0, Import=1, Resource=2, Exception=3, Security=4). + $secDirOff = $rvaCountOff + 4 + (4 * 8) + $secDirRva = [BitConverter]::ToUInt32($bytes, $secDirOff) + $secDirSize = [BitConverter]::ToUInt32($bytes, $secDirOff + 4) + if ($secDirRva -eq 0 -and $secDirSize -eq 0) { return $false } + for ($i = 0; $i -lt 8; $i++) { $bytes[$secDirOff + $i] = 0 } + [System.IO.File]::WriteAllBytes($FilePath, $bytes) + return $true +} + function Sign-File { param( [string]$FilePath, - [string]$Description = "" + [string]$Description = "", + [switch]$Force = $false ) if ($SkipSign) { @@ -154,49 +211,104 @@ function Sign-File { return $true } - # Check if already signed - try { - $sig = Get-AuthenticodeSignature $FilePath - if ($sig.Status -eq 'Valid') { - Write-Success "Already signed: $fileName" - return $true + if (-not $Force) { + # Check if already signed + try { + $sig = Get-AuthenticodeSignature $FilePath + if ($sig.Status -eq 'Valid') { + Write-Success "Already signed: $fileName" + return $true + } + } catch {} + } + + # Try each timestamp URL in order. signtool failures with a timestamp + # server are transient (network blip, server rotation) — retry against the + # next URL before declaring failure. Sign-without-timestamp is NOT a + # fallback: an untimestamped sig is valid only while the cert is — once + # the cert expires, every signed binary becomes "publisher unknown". + $lastResult = "" + $exitCode = 1 + foreach ($tsUrl in $TimestampUrls) { + $signArgs = @( + "sign", + "/sha1", $Thumbprint, + "/fd", "sha256", + "/tr", $tsUrl, + "/td", "sha256" + ) + + if ($Description) { + $signArgs += "/d" + $signArgs += $Description } - } catch {} - - $signArgs = @( - "sign", - "/sha1", $Thumbprint, - "/fd", "sha256", - "/tr", $TimestampUrl, - "/td", "sha256" - ) - if ($Description) { - $signArgs += "/d" - $signArgs += $Description - } + $signArgs += $FilePath - $signArgs += $FilePath + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $lastResult = & $SIGNTOOL @signArgs 2>&1 + $exitCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP - $prevEAP = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - $result = & $SIGNTOOL @signArgs 2>&1 - $exitCode = $LASTEXITCODE - $ErrorActionPreference = $prevEAP + if ($exitCode -eq 0) { break } + + # Distinguish a real signing failure (cert problem, file format) from + # a transient timestamp problem. Only the latter is worth retrying. + $resultStr = $lastResult -join ' ' + $isTimestampError = $resultStr -match "timestamp" -or $resultStr -match "RFC 3161" -or $resultStr -match "0x80096004" + if (-not $isTimestampError) { break } + Write-Host " [RETRY] Timestamp $tsUrl failed for $fileName, trying next..." -ForegroundColor Yellow + } if ($exitCode -eq 0) { Write-Success "Signed: $fileName" return $true - } else { - $resultStr = $result -join ' ' - if ($resultStr -match "not recognized" -or $resultStr -match "0x800700C1" -or $resultStr -match "BAD_EXE_FORMAT") { - Write-Host " [SKIP] Not signable format: $fileName" -ForegroundColor Gray + } + + $resultStr = $lastResult -join ' ' + + # 0x800700C1 / BAD_EXE_FORMAT on a syntactically-valid PE almost always + # means the file has a non-zero Security Directory pointing at malformed + # data (rcedit residue, or upstream Electrobun/launcher.exe shipped with + # a stale cert table). Strip the entry and retry once. See + # Clear-PECertTableEntry for the full rationale. + if (($resultStr -match "0x800700C1" -or $resultStr -match "BAD_EXE_FORMAT") -and (Clear-PECertTableEntry -FilePath $FilePath)) { + Write-Host " [FIX] Stripped stale cert-table entry, retrying: $fileName" -ForegroundColor Yellow + $signArgs = @( + "sign", + "/sha1", $Thumbprint, + "/fd", "sha256", + "/tr", $TimestampUrls[0], + "/td", "sha256" + ) + if ($Description) { $signArgs += "/d"; $signArgs += $Description } + $signArgs += $FilePath + + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $retryResult = & $SIGNTOOL @signArgs 2>&1 + $exitCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + + if ($exitCode -eq 0) { + Write-Success "Signed (after strip): $fileName" return $true } - Write-Error "Failed to sign: $fileName" - Write-Host " $result" -ForegroundColor Gray - return $false + $resultStr = $retryResult -join ' ' } + + # Genuinely-unsignable formats — bun shims (handled above by path) and + # native .node addons (handled above by extension) are the only files + # that should land here. If we still match "not recognized", it's a + # signtool-side rejection we can't fix; log and skip. + if ($resultStr -match "not recognized") { + Write-Host " [SKIP] Not signable format: $fileName" -ForegroundColor Gray + return $true + } + Write-Error "Failed to sign: $fileName" + Write-Host " $resultStr" -ForegroundColor Gray + return $false } # ============================================================================ @@ -258,11 +370,10 @@ if (-not $SkipSign) { if (-not $SkipBuild) { Write-Step "Updating git submodules (selective)" Push-Location $RepoRoot - # Only init the submodules we actually need -- recursive init pulls deeply - # nested firmware deps whose paths exceed Windows MAX_PATH (260 chars) + # Only init the submodules Vault packaging actually needs. Firmware is + # emulator-only for Vault releases and is intentionally not a build gate. git submodule update --init modules/hdwallet git submodule update --init modules/proto-tx-builder - git submodule update --init modules/keepkey-firmware git submodule update --init modules/device-protocol Pop-Location @@ -285,12 +396,15 @@ if (-not $SkipBuild) { Write-Step "Building proto-tx-builder" Push-Location (Join-Path $RepoRoot "modules\proto-tx-builder") bun install + if ($LASTEXITCODE -ne 0) { throw "bun install failed for proto-tx-builder (exit $LASTEXITCODE)" } Pop-Location Write-Step "Building hdwallet" Push-Location (Join-Path $RepoRoot "modules\hdwallet") yarn install + if ($LASTEXITCODE -ne 0) { throw "yarn install failed for hdwallet (exit $LASTEXITCODE)" } yarn build + if ($LASTEXITCODE -ne 0) { throw "yarn build failed for hdwallet (exit $LASTEXITCODE)" } Pop-Location Write-Step "Installing keepkey-vault dependencies" @@ -299,8 +413,18 @@ if (-not $SkipBuild) { # transitive deps inside file-linked workspace packages. These are not # needed at build time (collect-externals resolves them). Tolerate this. $ErrorActionPreference = 'Continue' - bun install + $vaultInstallOutput = bun install 2>&1 + $vaultInstallExit = $LASTEXITCODE $ErrorActionPreference = 'Stop' + if ($vaultInstallExit -ne 0) { + $vaultInstallText = @($vaultInstallOutput) -join "`n" + if ($vaultInstallText -match 'ENOENT' -and $vaultInstallText -match 'node_modules') { + Write-Warning "bun install exited $vaultInstallExit with nested node_modules ENOENT; continuing per Windows packaging workaround." + } else { + Write-Host $vaultInstallText -ForegroundColor Gray + throw "bun install failed for keepkey-vault (exit $vaultInstallExit)" + } + } Pop-Location Write-Step "Building zcash-cli sidecar (Rust)" @@ -318,21 +442,53 @@ if (-not $SkipBuild) { Write-Step "Building Electrobun Windows app" Push-Location $ProjectDir bun run build + if ($LASTEXITCODE -ne 0) { throw "bun run build failed for keepkey-vault (exit $LASTEXITCODE)" } Pop-Location - # Patch channel to stable -- Electrobun's --env=stable produces a macOS-style - # bundle on Windows that our installer can't use. Build as dev, patch to stable. + # Electrobun's Windows copy step can silently leave very deep nested + # node_modules packages incomplete. collect-externals.ts stages the verified + # runtime deps in _build/_ext_modules; mirror that exact tree into the final + # app bundle with robocopy, which handles Windows paths more reliably. + if (Test-Path $ExtModulesDir) { + Write-Step "Mirroring external node_modules into app bundle" + if (-not (Test-Path $AppNodeModulesDir)) { + New-Item -ItemType Directory -Force -Path $AppNodeModulesDir | Out-Null + } + & robocopy $ExtModulesDir $AppNodeModulesDir /MIR /R:1 /W:1 /XJ /NFL /NDL /NJH /NJS /NP /NS | Out-Null + $mirrorExit = $LASTEXITCODE + if ($mirrorExit -gt 7) { + throw "robocopy failed while mirroring external node_modules (exit $mirrorExit)" + } + $global:LASTEXITCODE = 0 + Write-Success "Mirrored external node_modules into Resources\app" + } else { + throw "collect-externals did not produce $ExtModulesDir" + } + + # Patch version.json: stable channel + force version to match package.json. + # Electrobun's `bun run build` is an incremental build — when a stale _build/ + # exists from a different branch, it can leave version.json with the wrong + # version. We had a current-version-named installer shipped with stale + # previous-version bits inside because + # of this. Force the version field rather than trust Electrobun's output. $VersionJson = Join-Path $BuildDir "Resources\version.json" - if (Test-Path $VersionJson) { - $vj = Get-Content $VersionJson -Raw | ConvertFrom-Json - $vj.channel = "stable" - $vj.name = "keepkey-vault" - $vj.hash = (Get-FileHash (Join-Path $BuildDir "Resources\app\bun\index.js") -Algorithm SHA256).Hash.ToLower().Substring(0, 16) - # Use .NET WriteAllText to avoid BOM -- PowerShell 5's -Encoding UTF8 writes a BOM - # which breaks JSON parsing in bun's require() - [System.IO.File]::WriteAllText($VersionJson, ($vj | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) - Write-Success "Patched version.json: channel=stable" + if (-not (Test-Path $VersionJson)) { + throw "Electrobun build did not produce $VersionJson -- build was broken or skipped." + } + $vj = Get-Content $VersionJson -Raw | ConvertFrom-Json + $electrobunSawVersion = $vj.version + $vj.channel = "stable" + $vj.name = "keepkey-vault" + $vj.version = $Version + $vj.hash = (Get-FileHash (Join-Path $BuildDir "Resources\app\bun\index.js") -Algorithm SHA256).Hash.ToLower().Substring(0, 16) + # Use .NET WriteAllText to avoid BOM -- PowerShell 5's -Encoding UTF8 writes a BOM + # which breaks JSON parsing in bun's require() + [System.IO.File]::WriteAllText($VersionJson, ($vj | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) + if ($electrobunSawVersion -ne $Version) { + Write-Warning "version.json reported $electrobunSawVersion but package.json says $Version -- forced to $Version." + Write-Warning "This usually means _build/ was stale from a prior branch. Consider deleting _build/ for next clean build." } + Write-Success "Patched version.json: version=$Version channel=stable" Write-Success "Build completed" } else { @@ -343,6 +499,9 @@ if (-not $SkipBuild) { if (-not (Test-Path $BuildDir)) { throw "Build directory not found: $BuildDir`nRun without -SkipBuild flag." } +if (-not (Test-Path $ExtModulesDir)) { + throw "External modules staging directory not found: $ExtModulesDir`nRun without -SkipBuild to regenerate collect-externals output." +} # ============================================================================ # Sign Executables and DLLs @@ -385,6 +544,14 @@ foreach ($file in $filesToSign) { Write-Host "" Write-Host " Signed: $signedCount, Failed: $failedCount" -ForegroundColor $(if ($failedCount -eq 0) { "Green" } else { "Yellow" }) +# Abort the release if any file failed to sign. Shipping an installer with +# mixed signed/unsigned PE files triggers SmartScreen warnings on the unsigned +# ones and breaks enterprise allowlists. Use -AllowSignFailures to opt out +# (e.g. when iterating on a build machine without the USB token plugged in). +if ($failedCount -gt 0 -and -not $AllowSignFailures -and -not $SkipSign) { + throw "Aborting: $failedCount file(s) failed to sign. Re-run with -AllowSignFailures if this is intentional." +} + # ============================================================================ # Prepare App Icon (convert PNG to ICO if needed) # ============================================================================ @@ -460,35 +627,61 @@ Write-Step "Building wrapper EXE" $WrapperExe = Join-Path $BuildDir "KeepKeyVault.exe" $WrapperSrc = Join-Path $ScriptDir "wrapper-launcher.zig" -if (-not (Test-Path $WrapperExe)) { - # Find Zig compiler +if ((Test-Path $WrapperExe) -and $SkipBuild) { + Write-Warning "Using existing wrapper EXE because -SkipBuild was supplied" +} else { + # Zig version pin: wrapper-launcher.zig was last updated for 0.15.2 + # (commit cfd6ea4). Zig 0.16 shipped an IO-context refactor that broke + # std.fs.cwd, std.time.milliTimestamp, and std.fs.selfExeDirPath. Refuse + # to use anything outside the supported series. + $SupportedZigPattern = '^0\.15\.' + $SupportedZigDescr = '0.15.x' $ZigExe = $null - $zigPaths = @( - (Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages\zig*" -Recurse -Filter "zig.exe" -ErrorAction SilentlyContinue | Select-Object -First 1) + # Preferred locations (most-specific first): pinned tools dir, then any + # 0.15.x install in tools/, then WinGet, then PATH. Only the explicit + # pin and a known-good winget package satisfy the version check below. + $zigSearchPaths = @( + "$env:USERPROFILE\tools\zig-x86_64-windows-0.15.1\zig.exe", + "$env:USERPROFILE\tools\zig-x86_64-windows-0.15.2\zig.exe" ) - foreach ($z in $zigPaths) { - if ($z) { $ZigExe = $z.FullName; break } + foreach ($p in $zigSearchPaths) { + if (Test-Path $p) { $ZigExe = $p; break } + } + if (-not $ZigExe) { + $toolsZigs = Get-ChildItem "$env:USERPROFILE\tools" -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'zig.*0\.15\.' } | Sort-Object Name -Descending + foreach ($d in $toolsZigs) { + $cand = Join-Path $d.FullName 'zig.exe' + if (Test-Path $cand) { $ZigExe = $cand; break } + } + } + if (-not $ZigExe) { + $wingetZig = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages\zig*" -Recurse -Filter "zig.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($wingetZig) { $ZigExe = $wingetZig.FullName } } if (-not $ZigExe) { $ZigExe = Get-Command "zig" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source } - if ($ZigExe) { - Write-Host " Using Zig: $ZigExe" -ForegroundColor Gray - Push-Location (Split-Path $WrapperSrc -Parent) - & $ZigExe build-exe $WrapperSrc -O ReleaseSmall --subsystem windows "-femit-bin=$WrapperExe" - Pop-Location + if (-not $ZigExe) { + throw "Zig compiler not found. Install Zig $SupportedZigDescr to `$env:USERPROFILE\tools\zig-x86_64-windows-0.15.1\ (download from https://ziglang.org/download/0.15.1/zig-x86_64-windows-0.15.1.zip)." + } - if ($LASTEXITCODE -eq 0) { - Write-Success "Built: KeepKeyVault.exe" - } else { - throw "Failed to compile wrapper EXE with Zig" - } + # Hard version check -- the source file is pinned to 0.15.x APIs. + $zigVer = (& $ZigExe version 2>&1).Trim() + if ($zigVer -notmatch $SupportedZigPattern) { + throw "Zig $zigVer at $ZigExe is unsupported. wrapper-launcher.zig requires Zig $SupportedZigDescr. Install 0.15.1 to `$env:USERPROFILE\tools\zig-x86_64-windows-0.15.1\." + } + + Write-Host " Using Zig: $ZigExe (version $zigVer)" -ForegroundColor Gray + Push-Location (Split-Path $WrapperSrc -Parent) + & $ZigExe build-exe $WrapperSrc -O ReleaseSmall --subsystem windows "-femit-bin=$WrapperExe" + Pop-Location + + if ($LASTEXITCODE -eq 0) { + Write-Success "Built: KeepKeyVault.exe" } else { - throw "Zig compiler not found. Install via: winget install zig.zig" + throw "Failed to compile wrapper EXE with Zig" } -} else { - Write-Success "Wrapper EXE already exists" } # Copy DPI-awareness manifest next to wrapper EXE @@ -503,7 +696,15 @@ if (Test-Path $ManifestSrc) { # Embed KeepKey icon into all EXEs # Electrobun's rcedit call fails (ENOENT -- hardcoded CI path), so we do it ourselves. +# +# ORDER MATTERS: rcedit must run BEFORE signing, OR the affected EXEs must be +# re-signed AFTER rcedit. rcedit modifies the .rsrc section via the Windows +# BeginUpdateResource API which invalidates Authenticode signatures +# (https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-beginupdateresourcea). +# The bulk sign loop above runs before the wrapper EXE exists, and we touch +# launcher.exe here too — so both get re-signed in the next step. $RceditExe = Join-Path $ProjectDir "node_modules\rcedit\bin\rcedit-x64.exe" +$rceditTouched = @() if ((Test-Path $IconIco) -and (Test-Path $RceditExe)) { # Skip bun.exe -- rcedit on 113MB binary can corrupt it; bun runs headless anyway $exesToIcon = @($WrapperExe, (Join-Path $BuildDir "bin\launcher.exe")) @@ -514,6 +715,7 @@ if ((Test-Path $IconIco) -and (Test-Path $RceditExe)) { & $RceditExe $exePath --set-icon $IconIco if ($LASTEXITCODE -eq 0) { Write-Success "Icon embedded into $exeName" + $rceditTouched += $exePath } else { Write-Warning "Failed to embed icon into $exeName" } @@ -523,6 +725,30 @@ if ((Test-Path $IconIco) -and (Test-Path $RceditExe)) { Write-Warning "rcedit not found - EXEs will use default icon" } +# Re-sign EXEs whose .rsrc was modified by rcedit (signatures invalidated above). +# Also sign the wrapper after the Zig build. On a clean build it did not exist +# during the bulk signing pass; when rcedit is unavailable it still must not ship +# unsigned. +$finalExeSignTargets = @() +if (Test-Path $WrapperExe) { $finalExeSignTargets += $WrapperExe } +foreach ($exePath in $rceditTouched) { + if ($finalExeSignTargets -notcontains $exePath) { $finalExeSignTargets += $exePath } +} +if (-not $SkipSign -and $finalExeSignTargets.Count -gt 0) { + Write-Step "Final signing wrapper/launcher EXEs" + $resignFailed = 0 + foreach ($exePath in $finalExeSignTargets) { + # Force re-sign because rcedit invalidates Authenticode signatures and + # Get-AuthenticodeSignature can briefly report stale validity. + if (-not (Sign-File -FilePath $exePath -Description $AppName -Force)) { + $resignFailed++ + } + } + if ($resignFailed -gt 0 -and -not $AllowSignFailures) { + throw "Aborting: $resignFailed wrapper/launcher EXE(s) failed final signing." + } +} + # ============================================================================ # Create Output Directory # ============================================================================ @@ -569,9 +795,45 @@ Write-Step "Preparing short-path staging for Inno Setup (MAX_PATH workaround)" $ShortStage = "C:\tmp\kk" if (Test-Path $ShortStage) { Remove-Item -Recurse -Force $ShortStage } Write-Host " Copying build to $ShortStage ..." -Copy-Item -Recurse -Force $BuildDir $ShortStage -$StagedFiles = (Get-ChildItem -Recurse -File $ShortStage | Measure-Object).Count -Write-Host " Staged $StagedFiles files (source path: $($ShortStage.Length) chars)" +# robocopy can handle source paths >260 chars when long-path support remains enabled. +# Critical flags (learned the hard way): +# /MT:16 — 16-thread copy. Single-threaded robocopy + Defender real-time +# scan = ~30 min for 14k files. Multi-threaded = ~1-2 min. +# /R:1 /W:1 — retry ONCE with a 1-sec wait. Defaults are /R:1000000 /W:30 +# (one million retries, 30-sec wait), which means a single +# Defender-locked file hangs the entire copy for hours. +# /XJ — skip junction points / reparse points. Without this, symlink +# loops inside nested node_modules can trap robocopy forever. +# robocopy exits 0-7 on success (0=no files, 1=copied, 2=extra, etc.) — normalize to 0 +$rcStart = Get-Date +robocopy $BuildDir $ShortStage /E /MT:16 /R:1 /W:1 /XJ /NFL /NDL /NJH /NJS /NP /NS | Out-Null +$stageCopyExit = $LASTEXITCODE +if ($stageCopyExit -gt 7) { + throw "robocopy failed while staging build for Inno Setup (exit $stageCopyExit)" +} +$global:LASTEXITCODE = 0 +$rcSeconds = [math]::Round(((Get-Date) - $rcStart).TotalSeconds, 1) +$StagedFiles = (Get-ChildItem -Recurse -File $ShortStage -ErrorAction SilentlyContinue | Measure-Object).Count +Write-Host " Staged $StagedFiles files in ${rcSeconds}s (source path: $($ShortStage.Length) chars)" + +# The build tree path itself is still long enough to drop very deep +# WalletConnect nested files while mirroring into Resources\app\node_modules. +# Overlay the collected externals directly into the short Inno source tree so +# installer packaging sees the complete dependency tree from a short path. +$StagedNodeModules = Join-Path $ShortStage "Resources\app\node_modules" +if (Test-Path $ExtModulesDir) { + Write-Host " Overlaying external node_modules into short stage ..." + robocopy $ExtModulesDir $StagedNodeModules /MIR /R:1 /W:1 /XJ /NFL /NDL /NJH /NJS /NP /NS | Out-Null + if ($LASTEXITCODE -gt 7) { + throw "robocopy failed while overlaying external node_modules into short stage (exit $LASTEXITCODE)" + } + $global:LASTEXITCODE = 0 + $WalletConnectProbe = Join-Path $StagedNodeModules "@walletconnect\sign-client\node_modules\@walletconnect\core\node_modules\@walletconnect\relay-auth\node_modules\uint8arrays\cjs\src\concat.js" + if (-not (Test-Path $WalletConnectProbe)) { + throw "Short-stage node_modules is incomplete; missing WalletConnect probe file: $WalletConnectProbe" + } + Write-Success "Short-stage external node_modules overlay verified" +} Write-Step "Building installer EXE with Inno Setup" @@ -605,7 +867,11 @@ if (-not $SkipSign) { Write-Step "Signing installer EXE" $signed = Sign-File -FilePath $InstallerExe -Description "$AppName Installer" if (-not $signed) { - Write-Error "Failed to sign the installer EXE!" + if ($AllowSignFailures) { + Write-Warning "Failed to sign the installer EXE; continuing because -AllowSignFailures was supplied." + } else { + throw "Failed to sign the installer EXE." + } } } @@ -615,7 +881,7 @@ if (-not $SkipSign) { Write-Step "Generating checksums" -$checksumFile = Join-Path $ArtifactsDir "SHA256SUMS.txt" +$checksumFile = Join-Path $ArtifactsDir "SHA256SUMS-windows.txt" $artifacts = Get-ChildItem -Path $ArtifactsDir -File | Where-Object { $_.Name -notlike "*.txt" } $checksums = @() @@ -626,7 +892,7 @@ foreach ($file in $artifacts) { } $checksums | Out-File -FilePath $checksumFile -Encoding UTF8 -Write-Success "Created: SHA256SUMS.txt" +Write-Success "Created: SHA256SUMS-windows.txt" # ============================================================================ # Summary @@ -651,7 +917,11 @@ foreach ($file in $finalArtifacts) { Write-Host "" if (-not $SkipSign) { - Write-Host "All executables have been signed with EV certificate." -ForegroundColor Green + if ($AllowSignFailures) { + Write-Host "Signing was attempted; failures were allowed by -AllowSignFailures." -ForegroundColor Yellow + } else { + Write-Host "All executables have been signed with EV certificate." -ForegroundColor Green + } Write-Host "" Write-Host "Next steps:" -ForegroundColor Yellow Write-Host " 1. Test the installer: run the setup EXE" -ForegroundColor Gray diff --git a/scripts/preflight-windows.ps1 b/scripts/preflight-windows.ps1 new file mode 100644 index 00000000..9906e6b7 --- /dev/null +++ b/scripts/preflight-windows.ps1 @@ -0,0 +1,390 @@ +<# +.SYNOPSIS + KeepKey Vault - Windows preflight check. + +.DESCRIPTION + Validates every build/sign prerequisite documented in + docs/WINDOWS-BUILD-QUIRKS.md. Run this BEFORE invoking + build-windows-production.ps1. + + Exits 0 if all checks pass, 1 otherwise. Each failing check prints a + pointer to the relevant section in WINDOWS-BUILD-QUIRKS.md. + +.PARAMETER Strict + Treat warnings as failures. + +.PARAMETER SkipSign + Don't check cert / EV token state (useful for non-signing dev builds). + +.EXAMPLE + .\scripts\preflight-windows.ps1 + +.EXAMPLE + .\scripts\preflight-windows.ps1 -Strict + +.EXAMPLE + .\scripts\preflight-windows.ps1 -SkipSign +#> + +param( + [switch]$Strict = $false, + [switch]$SkipSign = $false +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' + +# ── State ──────────────────────────────────────────────────────────────── +$script:Failures = @() +$script:Warnings = @() + +function Pass { + param([string]$Msg) + Write-Host " [OK] $Msg" -ForegroundColor Green +} +function Fail { + param([string]$Msg, [string]$Quirk = "") + $script:Failures += $Msg + Write-Host " [FAIL] $Msg" -ForegroundColor Red + if ($Quirk) { Write-Host " See docs/WINDOWS-BUILD-QUIRKS.md $Quirk" -ForegroundColor DarkGray } +} +function Warn { + param([string]$Msg, [string]$Quirk = "") + $script:Warnings += $Msg + Write-Host " [WARN] $Msg" -ForegroundColor Yellow + if ($Quirk) { Write-Host " See docs/WINDOWS-BUILD-QUIRKS.md $Quirk" -ForegroundColor DarkGray } +} +function Section { + param([string]$Name) + Write-Host "" + Write-Host "==> $Name" -ForegroundColor Cyan +} + +# Resolve repo root from script location +$ScriptDir = if ($PSCommandPath) { Split-Path -Path $PSCommandPath -Parent } else { Split-Path -Path $MyInvocation.MyCommand.Path -Parent } +$RepoRoot = Split-Path -Path $ScriptDir -Parent +$ProjectDir = Join-Path $RepoRoot "projects\keepkey-vault" +$BuildDir = Join-Path $ProjectDir "_build" + +Write-Host "" +Write-Host "============================================" -ForegroundColor Magenta +Write-Host " KeepKey Vault Windows preflight check " -ForegroundColor Magenta +Write-Host "============================================" -ForegroundColor Magenta + +# ── Git state ──────────────────────────────────────────────────────────── +Section "Git state" +try { + $branch = (& git -C $RepoRoot rev-parse --abbrev-ref HEAD 2>&1).Trim() + Pass "On branch: $branch" + $dirty = (& git -C $RepoRoot status --short 2>&1) + if ($dirty) { + Warn "Working tree is dirty (uncommitted changes present):" + $dirty -split "`n" | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + } else { + Pass "Working tree is clean" + } +} catch { + Fail "git is not available or repo is broken: $($_.Exception.Message)" +} + +# ── Project version ────────────────────────────────────────────────────── +Section "Project version" +$pkg = $null +$pkgJsonPath = Join-Path $ProjectDir "package.json" +if (-not (Test-Path $pkgJsonPath)) { + Fail "package.json not found at $pkgJsonPath" +} else { + try { + $pkg = Get-Content $pkgJsonPath -Raw | ConvertFrom-Json + Pass "package.json version: $($pkg.version)" + } catch { + Fail "Failed to parse package.json: $($_.Exception.Message)" + } +} + +# ── device-protocol/lib (quirk 8) ──────────────────────────────────────── +Section "device-protocol/lib" +$messagesPb = Join-Path $RepoRoot "modules\device-protocol\lib\messages_pb.js" +if (Test-Path $messagesPb) { + Pass "modules/device-protocol/lib/messages_pb.js present" +} else { + Fail "modules/device-protocol/lib/messages_pb.js MISSING" "quirk 8" + Write-Host " Build via Git-Bash: cd modules/device-protocol && npm install && npm run build" -ForegroundColor DarkGray +} + +# ── _build/ staleness (quirk 9) ────────────────────────────────────────── +Section "Build dir freshness" +if (Test-Path $BuildDir) { + $devBuild = Join-Path $BuildDir "dev-win-x64\keepkey-vault-dev" + if (Test-Path $devBuild) { + $oldest = Get-ChildItem $devBuild -File -Recurse -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime | + Select-Object -First 1 + if ($oldest) { + $age = ((Get-Date) - $oldest.LastWriteTime).TotalDays + if ($age -gt 1) { + Warn ("_build/ contains files older than 1 day (oldest: {0}, {1:N1} days)" -f $oldest.Name, $age) "quirk 9" + Write-Host " Recommend: Remove-Item -Recurse projects\keepkey-vault\_build" -ForegroundColor DarkGray + } else { + Pass "_build/ is recent (oldest file <1 day old)" + } + } + # version.json sanity (quirk 10) + $vj = Join-Path $devBuild "Resources\version.json" + if ((Test-Path $vj) -and ($pkg)) { + try { + $vjObj = Get-Content $vj -Raw | ConvertFrom-Json + if ($vjObj.version -eq $pkg.version) { + Pass "version.json matches package.json ($($pkg.version))" + } else { + Fail "version.json reports $($vjObj.version) but package.json says $($pkg.version)" "quirk 10" + Write-Host " The build script forces this match, but if you're not rebuilding it'll ship stale." -ForegroundColor DarkGray + } + } catch { + Warn "version.json present but unparseable" + } + } + } +} else { + Pass "_build/ is absent (clean state)" +} + +# ── Tool versions (quirks 1, 14) ───────────────────────────────────────── +Section "Build tools" + +# Zig (quirk 1) +$zigCandidates = @( + "$env:USERPROFILE\tools\zig-x86_64-windows-0.15.1\zig.exe", + "$env:USERPROFILE\tools\zig-x86_64-windows-0.15.2\zig.exe" +) +$zigExe = $null +foreach ($p in $zigCandidates) { + if (Test-Path $p) { $zigExe = $p; break } +} +if (-not $zigExe) { + $zigExe = (Get-Command zig -ErrorAction SilentlyContinue).Source +} +if (-not $zigExe) { + Fail "Zig compiler not found" "quirk 1" +} else { + $zigVer = (& $zigExe version 2>&1).Trim() + if ($zigVer -match '^0\.15\.') { + Pass "Zig version: $zigVer (at $zigExe)" + } else { + Fail "Zig $zigVer at $zigExe (need 0.15.x)" "quirk 1" + Write-Host " Install from https://ziglang.org/download/0.15.1/zig-x86_64-windows-0.15.1.zip" -ForegroundColor DarkGray + } +} + +# PowerShell +$psVer = $PSVersionTable.PSVersion.ToString() +if ($PSVersionTable.PSVersion.Major -ge 5) { + Pass "PowerShell version: $psVer" +} else { + Fail "PowerShell version $psVer is too old (need 5.1+)" +} + +# Bun +$bunExe = (Get-Command bun -ErrorAction SilentlyContinue).Source +if ($bunExe) { + $bunVer = (& bun --version 2>&1).Trim() + Pass "Bun: $bunVer (at $bunExe)" +} else { + Fail "Bun not found on PATH" + Write-Host " winget install Oven-sh.Bun" -ForegroundColor DarkGray +} + +# Yarn +$yarnExe = (Get-Command yarn -ErrorAction SilentlyContinue).Source +if ($yarnExe) { + $yarnVer = (& yarn --version 2>&1).Trim() + Pass "Yarn: $yarnVer (at $yarnExe)" +} else { + Fail "Yarn not found on PATH (required by modules/hdwallet)" + Write-Host " winget install Yarn.Yarn" -ForegroundColor DarkGray +} + +# Cargo (Rust) +$cargoExe = (Get-Command cargo -ErrorAction SilentlyContinue).Source +if ($cargoExe) { + $cargoVer = ((& cargo --version 2>&1) -split " ")[1] + Pass "Cargo: $cargoVer (at $cargoExe)" +} else { + Fail "Cargo not found on PATH (required by zcash-cli sidecar)" + Write-Host " winget install Rustlang.Rustup; rustup default stable" -ForegroundColor DarkGray +} + +# Inno Setup +$iscc = $null +$isccPaths = @( + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", + "C:\Program Files\Inno Setup 6\ISCC.exe", + "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +) +foreach ($p in $isccPaths) { if (Test-Path $p) { $iscc = $p; break } } +if ($iscc) { + Pass "Inno Setup: $iscc" +} else { + Fail "Inno Setup 6 not found" + Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor DarkGray +} + +# SignTool (only if signing) +if (-not $SkipSign) { + $sdkBase = "C:\Program Files (x86)\Windows Kits\10\bin" + $signtool = $null + if (Test-Path $sdkBase) { + $sdks = Get-ChildItem $sdkBase -Directory | Where-Object Name -match '^\d+\.\d+\.\d+\.\d+$' | Sort-Object Name -Descending + foreach ($sdk in $sdks) { + $cand = Join-Path $sdk.FullName "x64\signtool.exe" + if (Test-Path $cand) { $signtool = $cand; break } + } + } + if ($signtool) { + Pass "SignTool: $signtool" + } else { + Fail "SignTool not found - install Windows SDK" "quirk 19" + } +} + +# ── Shell script line endings (quirks 3, 12) ───────────────────────────── +Section "Shell script line endings" +$shScripts = @( + @(Get-ChildItem -Recurse "$ProjectDir\scripts" -Filter "*.sh" -ErrorAction SilentlyContinue) + @(Get-ChildItem -Recurse "$RepoRoot\scripts" -Filter "*.sh" -ErrorAction SilentlyContinue) +) +$crlfFiles = @() +foreach ($s in $shScripts) { + $head = [System.IO.File]::ReadAllBytes($s.FullName) + if ($head.Length -ge 2) { + # Look for CR before first LF + for ($i = 0; $i -lt [Math]::Min($head.Length, 4096); $i++) { + if ($head[$i] -eq 0x0A) { + if ($i -gt 0 -and $head[$i - 1] -eq 0x0D) { $crlfFiles += $s.FullName } + break + } + } + } +} +if ($crlfFiles.Count -eq 0) { + Pass "All $($shScripts.Count) .sh scripts have LF line endings" +} else { + Fail "$($crlfFiles.Count) .sh scripts have CRLF line endings (will fail bash parsing)" "quirk 3" + foreach ($f in $crlfFiles) { Write-Host " $f" -ForegroundColor DarkGray } + Write-Host " Fix: sed -i 's/\r`$//' " -ForegroundColor DarkGray +} + +# ── Long path support (quirk 5) ────────────────────────────────────────── +Section "Long path support" +$lpe = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name LongPathsEnabled -ErrorAction SilentlyContinue +if ($lpe -and $lpe.LongPathsEnabled -eq 1) { + Pass "NTFS LongPathsEnabled = 1" +} else { + Warn "NTFS LongPathsEnabled is not 1 - submodule deps may fail" "quirk 5" + Write-Host " Fix (admin): New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force" -ForegroundColor DarkGray +} +$gitLpRaw = & git config --get core.longpaths 2>&1 +$gitLp = if ($gitLpRaw) { ($gitLpRaw -join "`n").Trim() } else { "" } +if ($gitLp -eq "true") { + Pass "git core.longpaths = true" +} else { + Warn "git core.longpaths is not true" "quirk 5" + Write-Host " Fix: git config --system core.longpaths true" -ForegroundColor DarkGray +} + +# ── npm/Bun TLS state (quirk 2) ────────────────────────────────────────── +Section "TLS / package fetch state" +$npmrc = Join-Path $env:USERPROFILE ".npmrc" +if (Test-Path $npmrc) { + $npmContent = Get-Content $npmrc -Raw + if ($npmContent -match "strict-ssl\s*=\s*false") { + Warn "~/.npmrc has strict-ssl=false (corporate TLS inspection likely)" "quirk 2" + Write-Host " Bun does NOT honor this. If bun install fails with UNABLE_TO_VERIFY_LEAF_SIGNATURE," -ForegroundColor DarkGray + Write-Host " run: `$env:NODE_TLS_REJECT_UNAUTHORIZED='0'; bun install" -ForegroundColor DarkGray + } else { + Pass "~/.npmrc TLS config looks default" + } +} else { + Pass "~/.npmrc absent (default TLS state)" +} +$proxyVars = @(@($env:HTTP_PROXY, $env:HTTPS_PROXY) | Where-Object { $_ }) +if ($proxyVars.Count -gt 0) { + Warn "Proxy env vars set: $($proxyVars -join ', ')" +} else { + Pass "No proxy env vars set" +} + +# ── Defender exclusions (quirk 27) ─────────────────────────────────────── +Section "Windows Defender exclusions" +try { + $mp = Get-MpPreference -ErrorAction SilentlyContinue + if ($mp) { + $exPaths = @($mp.ExclusionPath) + $exProcs = @($mp.ExclusionProcess) + $repoExcluded = $exPaths | Where-Object { $RepoRoot -like "$_*" } + $stagingExcluded = $exPaths | Where-Object { "C:\tmp\kk" -like "$_*" } + if ($repoExcluded) { + Pass "Repo dir is excluded from Defender ($repoExcluded)" + } else { + Warn "Repo dir not in Defender exclusions (build will be slow)" "quirk 27" + Write-Host " Run as admin: Add-MpPreference -ExclusionPath '$RepoRoot'" -ForegroundColor DarkGray + } + if ($stagingExcluded) { + Pass "C:\tmp\kk staging is excluded" + } else { + Warn "C:\tmp\kk not in Defender exclusions" "quirk 27" + Write-Host " Run as admin: Add-MpPreference -ExclusionPath 'C:\tmp\kk'" -ForegroundColor DarkGray + } + $expectedProcs = @('signtool.exe', 'robocopy.exe', 'bun.exe', 'node.exe', 'cargo.exe', 'ISCC.exe') + $missing = $expectedProcs | Where-Object { $_ -notin $exProcs } + if ($missing.Count -eq 0) { + Pass "Defender process exclusions cover build tools" + } else { + Warn "Defender process exclusions missing: $($missing -join ', ')" "quirk 27" + } + } else { + Warn "Could not query Defender (Get-MpPreference unavailable in this shell)" + } +} catch { + Warn "Defender check failed: $($_.Exception.Message)" +} + +# ── EV signing cert (quirk 19) ─────────────────────────────────────────── +if (-not $SkipSign) { + Section "EV signing certificate" + $defaultThumb = "986AEBA61CF6616393E74D8CBD3A09E836213BAA" + $thumb = if ($env:KK_SIGN_THUMBPRINT) { $env:KK_SIGN_THUMBPRINT } else { $defaultThumb } + $cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue | + Where-Object Thumbprint -eq $thumb | + Select-Object -First 1 + if ($cert) { + $daysLeft = ($cert.NotAfter - (Get-Date)).TotalDays + Pass "Cert found: $($cert.Subject)" + Write-Host " Valid until $($cert.NotAfter), $([math]::Round($daysLeft)) days left" -ForegroundColor DarkGray + if ($daysLeft -lt 30) { + Warn "Cert expires in $([math]::Round($daysLeft)) days - rotate soon" + } + } else { + Fail "Cert thumbprint $thumb not found in any store" "quirk 19" + Write-Host " Plug in + unlock the EV USB token, or set `$env:KK_SIGN_THUMBPRINT to a different cert." -ForegroundColor DarkGray + } +} + +# ── Summary ────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "============================================" -ForegroundColor Magenta +$pass = if ($Strict) { $script:Failures.Count -eq 0 -and $script:Warnings.Count -eq 0 } else { $script:Failures.Count -eq 0 } +if ($pass) { + Write-Host " Preflight: PASS" -ForegroundColor Green + if ($script:Warnings.Count -gt 0) { + Write-Host " $($script:Warnings.Count) warning(s) - review before building." -ForegroundColor Yellow + } +} else { + Write-Host " Preflight: FAIL" -ForegroundColor Red + Write-Host " $($script:Failures.Count) failure(s), $($script:Warnings.Count) warning(s)" -ForegroundColor Red + Write-Host " Fix the failures above before running build-windows-production.ps1." -ForegroundColor Red +} +Write-Host "============================================" -ForegroundColor Magenta +Write-Host "" + +exit ($script:Failures.Count -gt 0 -or ($Strict -and $script:Warnings.Count -gt 0)) diff --git a/scripts/sign-macos-app.sh b/scripts/sign-macos-app.sh index 8578fe5b..f529d96f 100755 --- a/scripts/sign-macos-app.sh +++ b/scripts/sign-macos-app.sh @@ -74,7 +74,15 @@ codesign --verify --deep --strict "$APP_PATH" # --- 5. Spot-check: bun MUST have allow-jit --- BUN_BIN="$MACOS_DIR/bun" if [ -f "$BUN_BIN" ]; then - if ! codesign -d --entitlements :- "$BUN_BIN" 2>/dev/null | grep -q "allow-jit"; then + ENTITLEMENTS_OUT="$(codesign -d --entitlements :- "$BUN_BIN" 2>&1 || true)" + if echo "$ENTITLEMENTS_OUT" | grep -q "invalid entitlements blob"; then + echo "" + echo "FATAL: bun binary has an invalid entitlements blob." + echo "macOS will ignore invalid entitlements, causing SIGTRAP crashes." + echo "$ENTITLEMENTS_OUT" + exit 1 + fi + if ! echo "$ENTITLEMENTS_OUT" | grep -q "allow-jit"; then echo "" echo "FATAL: bun binary is missing allow-jit entitlement!" echo "This will cause SIGTRAP crashes at runtime." diff --git a/scripts/wrapper-launcher.zig b/scripts/wrapper-launcher.zig index 053c2ba1..5cd60c24 100644 --- a/scripts/wrapper-launcher.zig +++ b/scripts/wrapper-launcher.zig @@ -98,6 +98,7 @@ extern "kernel32" fn CreateProcessW(?[*:0]const u16, ?[*:0]u16, ?*anyopaque, ?*a extern "kernel32" fn CloseHandle(HANDLE) callconv(.winapi) BOOL; extern "kernel32" fn GetModuleHandleW(?[*:0]const u16) callconv(.winapi) HINSTANCE; extern "kernel32" fn Sleep(DWORD) callconv(.winapi) void; +extern "kernel32" fn SetEnvironmentVariableW([*:0]const u16, ?[*:0]const u16) callconv(.winapi) BOOL; // user32 extern "user32" fn RegisterClassExW(*const WNDCLASSEXW) callconv(.winapi) u16; @@ -400,15 +401,25 @@ pub fn main() !void { } // ── Launch the real app ───────────────────────────────────────── - const launcher_path = try std.fs.path.join(a, &.{ exe_dir, "bin", "launcher.exe" }); + // Launch through Electrobun's stock launcher so BrowserWindow initializes + // on the supported runtime path. Export BUN_OPTIONS so the launcher's Bun + // child uses Windows' system CA store; without this, Bun's bundled CA set + // can reject valid corporate/Windows trust chains and break api.keepkey.info. + const bin_dir = try std.fs.path.join(a, &.{ exe_dir, "bin" }); + _ = SetEnvironmentVariableW( + std.unicode.utf8ToUtf16LeStringLiteral("BUN_OPTIONS"), + std.unicode.utf8ToUtf16LeStringLiteral("--use-system-ca"), + ); + const launcher_path = try std.fs.path.join(a, &.{ bin_dir, "launcher.exe" }); const cmd = try std.fmt.allocPrint(a, "\"{s}\"", .{launcher_path}); + const launcher_path_w = try std.unicode.utf8ToUtf16LeAllocZ(a, launcher_path); const cmd_w = try std.unicode.utf8ToUtf16LeAllocZ(a, cmd); - const cwd_w = try std.unicode.utf8ToUtf16LeAllocZ(a, exe_dir); + const cwd_w = try std.unicode.utf8ToUtf16LeAllocZ(a, bin_dir); var si = STARTUPINFOW{}; var pi = PROCESS_INFORMATION{}; - const ok = CreateProcessW(null, cmd_w, null, null, 0, CREATE_NO_WINDOW, null, cwd_w, &si, &pi); + const ok = CreateProcessW(launcher_path_w, cmd_w, null, null, 0, CREATE_NO_WINDOW, null, cwd_w, &si, &pi); if (ok != 0) { if (pi.hProcess) |h| _ = CloseHandle(h); if (pi.hThread) |h| _ = CloseHandle(h);