diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e0c0314..4a5f8b86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,13 +21,20 @@ concurrency: env: BUN_VERSION: '1.3.5' NODE_VERSION: '20' + EMU_RELEASE_VERSION: '7.16.0' + EMU_SOURCE_RUN_ID: '33047449262' + EMU_SOURCE_SHA: '7f802ba261fadebe71ee794042d0ac89d564a82d' + EMU_SOURCE_ARTIFACT: 'keepkey-vault-macos-7f802ba261fadebe71ee794042d0ac89d564a82d' jobs: build: name: Build (${{ matrix.name }}) runs-on: ${{ matrix.runner }} + permissions: + actions: read + contents: read continue-on-error: ${{ matrix.experimental || false }} - timeout-minutes: 30 + timeout-minutes: 45 strategy: fail-fast: false @@ -47,7 +54,8 @@ jobs: uses: actions/checkout@v4 - name: Init required submodules - run: git submodule update --init modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun + run: | + git submodule update --init modules/hdwallet modules/proto-tx-builder modules/device-protocol modules/electrobun - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -154,10 +162,34 @@ jobs: run: cd projects/keepkey-vault && bun install --frozen-lockfile shell: bash - - name: Install protoc (macOS) + - name: Run Vault unit and submodule-contract tests + # Must run after building the pinned modules. The suite imports critical + # hdwallet wire translators directly, so an incompatible gitlink fails + # here instead of producing a package with missing account controls. + run: make test-unit + shell: bash + + - name: Install native build tools (macOS) if: runner.os == 'macOS' + # Protoc is required by the Rust Zcash sidecar. The certified emulator + # is downloaded as an immutable artifact and is never rebuilt here. run: brew install protobuf + - name: Download certified emulator 7.16 artifact + if: runner.os == 'macOS' + uses: actions/download-artifact@v4 + with: + name: ${{ env.EMU_SOURCE_ARTIFACT }} + path: .certified-emulator-${{ env.EMU_RELEASE_VERSION }} + github-token: ${{ github.token }} + repository: keepkey/keepkey-vault + run-id: ${{ env.EMU_SOURCE_RUN_ID }} + + - name: Stage and verify certified emulator 7.16 libraries + if: runner.os == 'macOS' + run: scripts/stage-certified-emulator.sh ".certified-emulator-${EMU_RELEASE_VERSION}" + shell: bash + - name: Install Linux packaging tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y fakeroot lintian dpkg-dev @@ -178,6 +210,14 @@ jobs: run: cd projects/keepkey-vault && bun run build:stable shell: bash + - name: Stage Windows emulator build input + if: runner.os == 'macOS' + run: | + cp projects/keepkey-vault/emulator-bundle/libkkemu.dll \ + projects/keepkey-vault/artifacts/emulator-build-input-libkkemu-7.16.0-win-x64.dll + shasum -a 256 projects/keepkey-vault/artifacts/emulator-build-input-libkkemu-7.16.0-win-x64.dll + shell: bash + - name: Prune app bundle run: cd projects/keepkey-vault && bun scripts/prune-app-bundle.ts shell: bash @@ -291,12 +331,22 @@ jobs: fi done - # Remove zcash-cli from x64 bundle (Zcash shielded not supported on Intel) + # The ARM64 release must contain the shielded sidecar. Intel does not + # support it yet, so the x64 derivative removes it and Vault gates the + # privacy capability on the binary being present at runtime. ZCASH_DEST=$(find "$APP" -name "zcash-cli" -type f | head -1) - if [ -n "$ZCASH_DEST" ]; then - rm "$ZCASH_DEST" - echo " zcash-cli: removed (Intel not supported)" + if [ -z "$ZCASH_DEST" ]; then + echo "::error::ARM64 source app is missing zcash-cli" + exit 1 + fi + ZCASH_ARCH=$(lipo -archs "$ZCASH_DEST" 2>/dev/null || echo "non-macho") + echo " zcash-cli source: $ZCASH_ARCH" + if [[ "$ZCASH_ARCH" != *"arm64"* ]]; then + echo "::error::ARM64 source app contains incompatible zcash-cli ($ZCASH_ARCH)" + exit 1 fi + rm "$ZCASH_DEST" + echo " zcash-cli: removed (Intel not supported; privacy capability will stay disabled)" # Verify key binaries are now x86_64 echo "Verifying swapped binaries:" @@ -778,6 +828,9 @@ jobs: - name: Generate combined checksums run: | cd artifacts + # This unsigned DLL is a Windows release build input, not a public + # release asset. The Windows production script Authenticode-signs it. + rm -f emulator-build-input-*.dll # Remove per-platform checksum files (will regenerate combined) rm -f SHA256SUMS-*.txt SHA256SUMS.txt # Generate checksums for release-worthy files only diff --git a/.gitignore b/.gitignore index 53b278cb..6c8d8710 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,11 @@ firmware/emulators/7.14.0-*/ # Emulator build directory modules/keepkey-firmware/build-emu/ +modules/keepkey-firmware/build-emu-*/ + +# Release-staged emulator libraries (rebuilt from the pinned firmware gitlink) +projects/keepkey-vault/emulator-bundle/*.dylib +projects/keepkey-vault/emulator-bundle/*.dll # Release artifacts (belong in GitHub Releases, not the repo) release-windows/ diff --git a/.gitmodules b/.gitmodules index 7456beb0..761ece28 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,7 +7,7 @@ [submodule "modules/keepkey-firmware"] path = modules/keepkey-firmware url = https://github.com/BitHighlander/keepkey-firmware - branch = release/7.14.0 + branch = alpha [submodule "modules/device-protocol"] path = modules/device-protocol url = https://github.com/BitHighlander/device-protocol diff --git a/Makefile b/Makefile index 99bc1249..96b9d896 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,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-sign-gating 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 build-emulator-windows clean-emulator test-emu-python +.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-sign-gating 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 build-emulator-windows build-emulator-macos-release build-emulator-release clean-emulator test-emu-python # --- Submodules (auto-init on fresh worktrees/clones) --- @@ -31,8 +31,7 @@ $(STAMP_DIR): $(SUBMODULES_STAMP): .gitmodules | $(STAMP_DIR) @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. + @# Firmware pinning is outside Vault release scope. Fetch runtime/build modules 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 @@ -306,6 +305,7 @@ prune-bundle: # Clearing the stamps forces modules-build from the pinned source before the vault install copies it. build-signed: sign-check @rm -f $(ZCASH_CLI_STAMP) $(PROTO_BUILD_STAMP) $(HDWALLET_BUILD_STAMP) $(DEVICE_PROTOCOL_BUILD_STAMP) + @node scripts/verify-certified-emulator.mjs $(MAKE) build-stable audit prune-bundle dmg @echo "" @echo "=== Build complete ===" @@ -348,8 +348,12 @@ dmg: verify-arch test: test-zcash-cli test-unit test-unit: - cd $(PROJECT_DIR) && bun test __tests__/evm-signer-verify.test.ts __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/device-switch.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__/solana-spl-decimals.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts __tests__/audit-coverage.test.ts __tests__/chain-scan.test.ts __tests__/taproot-host.test.ts __tests__/recovery-ownership.test.ts __tests__/evm-x402.test.ts __tests__/solana-x402.test.ts __tests__/patch-electrobun.test.ts src/bun/mcp.test.ts src/bun/rng-audit.test.ts src/shared/zcash-maturity.test.ts src/bun/txbuilder/utxo-zcash.test.ts src/bun/txbuilder/utxo-taproot.test.ts src/bun/txbuilder/hive-ops.test.ts src/bun/clearsign-studio.test.ts src/bun/solana-outflow.test.ts + cd $(PROJECT_DIR) && bun test __tests__/evm-signer-verify.test.ts __tests__/evm-balance-fetch.test.ts __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/device-switch.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__/solana-spl-decimals.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts __tests__/audit-coverage.test.ts __tests__/chain-scan.test.ts __tests__/pairing-pubkeys.test.ts __tests__/balance-display-state.test.ts __tests__/failed-fetch-not-zero.test.ts __tests__/advanced-mode-routing.test.ts __tests__/clearsign-provider-key.test.ts __tests__/firmware-clearsign-gate.test.ts __tests__/taproot-host.test.ts __tests__/solana-hdwallet-contract.test.ts __tests__/recovery-ownership.test.ts __tests__/evm-x402.test.ts __tests__/solana-x402.test.ts __tests__/patch-electrobun.test.ts src/bun/emulator-library.test.ts src/bun/mcp.test.ts src/bun/rng-audit.test.ts src/shared/zcash-maturity.test.ts src/bun/zcash-capability.test.ts src/bun/zcash-sidecar-path.test.ts src/bun/txbuilder/utxo-zcash.test.ts src/bun/txbuilder/utxo-taproot.test.ts src/bun/txbuilder/hive-ops.test.ts src/bun/clearsign-studio.test.ts src/bun/solana-outflow.test.ts cd $(PROJECT_DIR) && bun src/bun/btc-backend/core.test.ts + # Script-style suites (own runner + process.exit — must NOT join the `bun test` + # list above, where the exit would cut the run short). cosmos.test.ts was green + # but unwired, so its 10 assertions had never guarded a release. + cd $(PROJECT_DIR) && bun src/bun/txbuilder/cosmos.test.ts test-integration: test-rest @@ -403,6 +407,11 @@ test-emu: EMU_FW_DIR := modules/keepkey-firmware EMU_BUILD_DIR := $(EMU_FW_DIR)/build-emu EMU_INSTALL_DIR := $(HOME)/.keepkey/emulator +# Vault's developer emulator must carry the same alpha ClearSign root as the +# firmware CI emulator. A rootless build is deliberately fail-closed, but it +# cannot exercise certified 7.16 flows and otherwise looks healthy until the +# first certificate reaches the device. +EMU_CLEARSIGN_ALPHA_ROOT ?= ON build-emulator: @echo "=== Building emulator from current $(EMU_FW_DIR) checkout ===" @@ -424,11 +433,14 @@ build-emulator: if [ -x "$$PINNED/protoc" ]; then export PATH="$$PINNED:$$PYBIN:$$NANOPB_DIR/generator:$$PATH"; \ else export PATH="$$PYBIN:$$NANOPB_DIR/generator:$$PATH"; fi; \ cmake .. -DKK_EMULATOR=ON -DKK_DEBUG_LINK=ON -DKK_BUILD_DYLIB=ON \ + -DKK_CLEARSIGN_ALPHA_ROOT=$(EMU_CLEARSIGN_ALPHA_ROOT) \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ -DNANOPB_DIR="$$NANOPB_DIR" \ -DNANOPB_PLUGIN="$$(command -v protoc-gen-nanopb)" \ -DCMAKE_C_FLAGS="-DPB_NO_PACKED_STRUCTS=1" \ -DCMAKE_CXX_FLAGS="-DPB_NO_PACKED_STRUCTS=1" && \ + grep -qx 'KK_CLEARSIGN_ALPHA_ROOT:BOOL=$(EMU_CLEARSIGN_ALPHA_ROOT)' CMakeCache.txt || \ + { echo "ERROR: emulator ClearSign root configuration did not stick"; exit 1; }; \ make -j$$(sysctl -n hw.ncpu) kkemu kkemulator_dylib mkdir -p $(EMU_INSTALL_DIR) @if [ -f $(EMU_BUILD_DIR)/lib/libkkemu.dylib ]; then \ @@ -449,9 +461,19 @@ build-emulator: # standalone UDP `kkemu` binary (gated out on Windows). Requires mingw-w64: # macOS: brew install mingw-w64 | Linux: apt-get install mingw-w64 build-emulator-windows: - cd $(EMU_FW_DIR) && git submodule update --init --recursive + cd $(EMU_FW_DIR) && git submodule update --init code-signing-keys deps/crypto/trezor-firmware deps/device-protocol deps/googletest deps/python-keepkey deps/qrenc/QR-Code-generator deps/sca-hardening/SecAESSTM32 + cd $(EMU_FW_DIR)/deps/python-keepkey && git submodule update --init keepkeylib/eth/ethereum-lists bash scripts/build-emulator-windows.sh +# Release deliverables: universal macOS dylib plus Windows x64 DLL. Both scripts +# bind to the Vault's exact firmware gitlink and reject anything but 7.16. +build-emulator-macos-release: + cd $(EMU_FW_DIR) && git submodule update --init code-signing-keys deps/crypto/trezor-firmware deps/device-protocol deps/googletest deps/python-keepkey deps/qrenc/QR-Code-generator deps/sca-hardening/SecAESSTM32 + cd $(EMU_FW_DIR)/deps/python-keepkey && git submodule update --init keepkeylib/eth/ethereum-lists + bash scripts/build-emulator-macos-release.sh + +build-emulator-release: build-emulator-macos-release build-emulator-windows + # Run python-keepkey consistency tests against the locally-built kkemu binary. test-emu-python: @test -x $(EMU_INSTALL_DIR)/kkemu || \ @@ -803,7 +825,8 @@ help: @echo " make clean - Remove all build artifacts and node_modules" @echo " make preflight - Pre-release validation (pins, CI, builds, typecheck)" @echo "" - @echo "Emulator (developer feature, macOS only):" + @echo "Emulator:" + @echo " make build-emulator-release - Build + audit bundled 7.16 macOS universal and Windows x64 libraries" @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" @@ -824,10 +847,11 @@ preflight: submodules else echo " ❌ $$mod DRIFT (pin=$$pinned actual=$$actual)"; fail=1; fi; \ done; \ echo ""; \ - echo "2. FIRMWARE SUBMODULE"; \ - echo " ⚠️ Skipped for Vault release gating (emulator/firmware work only)"; \ + echo "2. CERTIFIED EMULATOR ARTIFACTS"; \ + if node scripts/verify-certified-emulator.mjs >/dev/null 2>&1; then echo " ✅ certified emulator 7.16.0 hashes and ABI"; \ + else echo " ❌ certified emulator artifacts missing or invalid — run scripts/stage-certified-emulator.sh "; fail=1; fi; \ echo ""; \ - echo "3. UPSTREAM BEHIND"; \ + echo "3. CANONICAL BRANCHES"; \ 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 "?"); \ @@ -836,7 +860,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/electrobun|blackboardsh/electrobun|blackboardsh/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|BitHighlander/device-protocol|BitHighlander/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); \ @@ -862,7 +886,14 @@ preflight: submodules && echo " ✅ device-protocol Ironwood fields" \ || { echo " ❌ device-protocol pin is missing Ironwood fields 19/20"; fail=1; }; \ echo ""; \ - echo "6. VAULT TYPECHECK (differential vs baseline)"; \ + echo "6. HOST/SUBMODULE CONTRACT TESTS"; \ + if (cd $(PROJECT_DIR) && bun test __tests__/taproot-host.test.ts >/dev/null 2>&1); then \ + echo " ✅ Bitcoin account + pinned hdwallet Taproot contract"; \ + else \ + echo " ❌ Bitcoin/hdwallet contract failed — run: cd $(PROJECT_DIR) && bun test __tests__/taproot-host.test.ts"; fail=1; \ + fi; \ + echo ""; \ + echo "7. VAULT TYPECHECK (differential vs baseline)"; \ errs=$$(cd $(PROJECT_DIR) && npx tsc --noEmit --skipLibCheck 2>&1 | grep "error TS" | grep -v "minimatch" | wc -l | tr -d ' '); \ base=$$(cat $(PROJECT_DIR)/.typecheck-baseline 2>/dev/null || echo 0); \ if [ "$$errs" = "0" ]; then echo " ✅ clean"; \ diff --git a/docs/HANDOFF-PIONEER-ETH-RUNE-BALANCE-TIMEOUTS-2026-08-26.md b/docs/HANDOFF-PIONEER-ETH-RUNE-BALANCE-TIMEOUTS-2026-08-26.md new file mode 100644 index 00000000..3636a367 --- /dev/null +++ b/docs/HANDOFF-PIONEER-ETH-RUNE-BALANCE-TIMEOUTS-2026-08-26.md @@ -0,0 +1,129 @@ +# Pioneer handoff: ETH/RUNE degraded balance responses + +Date: 2026-08-26 +Reporter: KeepKey Vault live dev session +Pioneer base: `https://api.keepkey.info/api/v1` + +## Ownership summary + +Vault is correctly consuming Pioneer's `PortfolioResponseV2.meta` contract. It +keeps the last known balance when a fresh fetch fails and surfaces degraded or +stale chains instead of presenting an unverified zero. No Vault balance-parser +change is required for this incident. + +The remaining failure is Pioneer-side. ETH was transiently degraded but +recovered during isolation. THORChain/RUNE remains reproducibly degraded. + +## Live evidence + +The original dashboard refresh completed successfully at the HTTP level and +returned 115 portfolio entries, but Pioneer metadata reported: + +```text +degraded=[ETH, RUNE] +stale=[RUNE] +``` + +A subsequent forced, single-chain ETH request recovered: + +```text +chain: ETH +balanceRows: 6 +meta.degraded: false +meta.failures: [] +meta.staleChains: [] +meta.serverMs: 1321 +traceId: 1b8e025d-c016-4b96-a002-fe4f4ac8f5da +``` + +A forced, single-chain THORChain request remained broken: + +```text +chain: RUNE +balanceRows: 41 +meta.degraded: true +meta.degradedCount: 41 +meta.failures: 41 entries, each reason="timeout" +meta.staleChains: [] +meta.serverMs: 8115 +traceId: e0b21b5a-a4cd-40cf-9c05-376903457c74 +``` + +The 41 failures include native RUNE plus every synthetic THORChain denom +expanded for the same owner. This strongly suggests one upstream THORChain +account/provider timeout is being fanned out into an asset-level failure for +every denom. + +Pioneer's node diagnostic routes are also broken in production: + +```text +GET /api/v1/api/nodes/health +500 this.collection.find(...).toArray is not a function + +GET /api/v1/api/nodes/cosmos%3Athorchain-mainnet-v1/all +500 this.collection.find(...).sort is not a function + +GET /api/v1/api/nodes/cosmos%3Athorchain-mainnet-v1/best +500 Node type is required +``` + +The collection-adapter errors may also affect the provider-selection path used +by portfolio balances. Confirm this from server logs before treating it as the +sole root cause. + +## Reproduction + +Use an existing registered Pioneer query key and any valid THORChain address: + +```bash +curl -sS \ + -H "Authorization: $PIONEER_QUERY_KEY" \ + -H 'Content-Type: application/json' \ + --data '{"pubkeys":[{"caip":"cosmos:thorchain-mainnet-v1/slip44:931","pubkey":""}]}' \ + 'https://api.keepkey.info/api/v1/portfolio?forceRefresh=true' +``` + +Inspect `meta.degraded`, `meta.failures`, `meta.staleChains`, `meta.serverMs`, +and `meta.traceId`. Do not log the authorization key or full wallet address in +the issue or deployment logs. + +## Pioneer work + +1. Look up trace `e0b21b5a-a4cd-40cf-9c05-376903457c74` and identify the + selected THORChain provider/node, timeout boundary, and retry count. +2. Repair the node repository/collection adapter used by the health and list + routes. The production object does not implement the chained Mongo methods + those controllers expect. +3. Verify whether portfolio provider selection shares that broken adapter. +4. Fetch a THORChain account once per owner/network and fan out successful + balances locally. Do not execute or report 41 independent upstream timeouts + for one address. +5. Preserve the last successful chain snapshot on timeout, but report one + chain-level failure with provider, attempt count, and timeout class in + structured metadata. Avoid leaking node credentials or wallet identifiers. +6. Add provider failover/circuit-breaker coverage so one dead THORChain node is + removed from selection before the next dashboard retry. + +## Acceptance criteria + +- Forced native RUNE portfolio request returns `meta.degraded=false` within the + normal portfolio SLA. +- Native RUNE is present and reflects the live THORChain account balance. +- One failed upstream account lookup is not multiplied into 41 independent + network calls/timeouts. +- `/api/nodes/health`, `/api/nodes/:networkId/all`, and + `/api/nodes/:networkId/best` return 200 for THORChain. +- A deliberately failed primary provider selects a healthy fallback and records + the provider transition in internal telemetry. +- If every provider fails, Pioneer returns cached data plus explicit structured + degraded/stale metadata; Vault continues to warn and never presents the + result as a verified zero. +- Regression test covers ETH healthy + THORChain timeout in the same portfolio + request, proving the healthy chain remains confirmed. + +## Vault verification after Pioneer deploy + +1. Refresh the dashboard with `forceRefresh=true`. +2. Confirm the soft-fault banner clears without restarting Vault. +3. Confirm ETH and RUNE rows have `syncState='confirmed'`. +4. Repeat after disabling the primary THORChain provider to validate failover. diff --git a/docs/RETRO-BITCOIN-XPUB-SELECTOR-SUBMODULE-2026-08-26.md b/docs/RETRO-BITCOIN-XPUB-SELECTOR-SUBMODULE-2026-08-26.md new file mode 100644 index 00000000..b261ed9e --- /dev/null +++ b/docs/RETRO-BITCOIN-XPUB-SELECTOR-SUBMODULE-2026-08-26.md @@ -0,0 +1,98 @@ +# Retro: Bitcoin xpub selector disappeared after hdwallet pin rollback + +Date: 2026-08-26 +Affected surface: Vault Bitcoin asset page / bitcoin-only dashboard +User-visible symptom: the account and script-type selector area was blank + +## Executive summary + +Vault's Taproot discovery code and its pinned hdwallet implementation fell out +of sync during a parent-repository merge. Vault asked the device for four +Bitcoin account xpubs, including P2TR. The selected hdwallet pin claimed P2TR +support but could not translate `p2tr` into the device protocol, so the entire +batch threw. The account manager remained empty, and the frontend intentionally +rendered nothing for an empty account set while also swallowing the RPC error. + +The selector component itself had not been removed. Repeated UI repairs could +not make the control reliable because its required data producer was failing. + +## Evidence and timeline + +- `f797ad5e5` added capability-gated Taproot discovery to Vault. +- hdwallet `0572619c` added the corresponding host enum, feature gate, wire + translators, and adapter tests. It is contained by hdwallet `87553b99`. +- PR-425 merge `69b19f07d` had parent 1 pinned to `87553b99` and parent 2 + pinned to older ClearSign branch commit `4e012e67`. +- The merge result selected `4e012e67`. That pin predates the Taproot host work. +- The running backend repeatedly logged: + + ```text + [getBalances] BTC accounts init failed: unhandled InputSriptType enum: p2tr + ``` + +## Failure chain + +1. Vault called `btcSupportsScriptType('Bitcoin', 'p2tr')`. +2. The older adapter returned `true` for the unknown value because its predicate + rejected only known unsupported SegWit combinations. +3. `BtcAccountManager` put BIP44, BIP49, BIP84, and BIP86 into one + `getPublicKeys` batch. +4. hdwallet's old `translateInputScriptType` threw on `p2tr`. +5. Initialization had already reset `accounts` and never published account 0. +6. `AssetPage` rendered the selector only when `accounts.length > 0`. +7. `useBtcAccounts` discarded the initialization exception, leaving a blank + layout slot with no retry or diagnostic. + +## Why existing checks missed it + +- The Vault Taproot capability test used a permissive fake wallet whose + `getPublicKeys` accepted every string. It tested Vault's desired contract, not + the pinned KeepKey adapter's real wire translator. +- hdwallet had correct Taproot tests on its newer branch, but parent-repository + CI did not test the compatibility of the exact submodule gitlink selected by + a merge. +- The parent build workflow built the application but did not run Vault's unit + suite. +- Preflight checked that the working-tree SHA matched the parent gitlink and + whether that SHA's own CI was green. It did not prove that the gitlink was + compatible with the Vault code consuming it. +- An optional capability shared one all-or-nothing batch with the three required + Bitcoin account types. +- The frontend converted a backend contract failure into absence of UI. + +## Corrective changes + +1. Derive the required BIP44/BIP49/BIP84 xpubs first and validate every result. +2. Derive optional BIP86 separately. A P2TR adapter failure is logged and + degrades to the required three types instead of taking all Bitcoin accounts + offline. +3. Treat a missing required xpub as an actionable initialization error. +4. Preserve the error in `useBtcAccounts`, retry when the device reaches + `ready`, and render an error/retry control instead of an empty slot. +5. Import the checked-out hdwallet core enum and KeepKey translators directly + from the Vault Taproot regression test. An old gitlink now fails the test. +6. Run the complete Vault unit suite in parent-repository CI after building the + exact pinned modules. +7. Run the fast Bitcoin/hdwallet compatibility test during release preflight. + +## Release gates + +A release or merge is blocked unless all of these are true: + +- The pinned hdwallet exposes `BTCInputScriptType.SpendTaproot` and + `BTCOutputScriptType.PayToTaproot`. +- Its KeepKey translators map them to protocol `SPENDTAPROOT` and + `PAYTOTAPROOT`. +- A wallet that falsely claims P2TR support but throws on P2TR still initializes + Legacy, SegWit, and Native SegWit accounts. +- A missing required xpub fails explicitly and the Bitcoin page presents retry + UI. +- `make test-unit` passes against freshly initialized, built submodules. + +## Merge discipline + +Submodule conflicts are API dependency decisions, not ordinary one-line merge +conflicts. Reviewers must compare both gitlink parents and choose or create a +descendant containing every required capability. A merge must never resolve a +gitlink solely by taking `ours` or `theirs`; the consuming repository's contract +tests are the authority. diff --git a/docs/WINDOWS-BUILD-AND-SIGN.md b/docs/WINDOWS-BUILD-AND-SIGN.md index fd2f6cbe..18cc0a2a 100644 --- a/docs/WINDOWS-BUILD-AND-SIGN.md +++ b/docs/WINDOWS-BUILD-AND-SIGN.md @@ -10,6 +10,8 @@ The build, signing, and installer steps are all driven by **one PowerShell scrip ```powershell # From the repo root (PowerShell 5.1+), USB EV signing token plugged in: +# First download the matching CI artifact's emulator-build-input DLL and save it as: +# projects\keepkey-vault\emulator-bundle\libkkemu.dll .\scripts\preflight-windows.ps1 -Strict .\scripts\build-windows-production.ps1 ``` @@ -52,6 +54,10 @@ A fully provisioned build machine. If this is a fresh box, follow [`WINDOWS-DEV- **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) +- `projects/keepkey-vault/emulator-bundle/libkkemu.dll` must be the + `emulator-build-input-libkkemu-7.16.0-win-x64.dll` from the matching commit's + macOS CI artifact. The build rejects a missing/non-PE DLL and verifies that + Electrobun copied the exact SHA-256 into the app. --- @@ -68,7 +74,10 @@ Only initializes the modules the build actually needs: - `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. +The Windows machine does not compile firmware. CI cross-builds the DLL from the +Vault's exact firmware gitlink, verifies 7.16.0, the ClearSign alpha root, all +12 FFI exports, and system-only imports. The PowerShell build consumes that +verified DLL and Authenticode-signs the copy inside the app. ### 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). @@ -298,6 +307,7 @@ 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 +- [ ] Matching CI DLL is staged at `projects\keepkey-vault\emulator-bundle\libkkemu.dll` - [ ] EV token plugged in, unlocked, certificate visible - [ ] Run `.\scripts\preflight-windows.ps1 -Strict` - [ ] Run `.\scripts\build-windows-production.ps1` @@ -310,3 +320,5 @@ Before tagging and uploading: - [ ] Compare `SHA256SUMS-windows.txt` against the `.zip` hash - [ ] Upload the **`.zip`** (NOT a bare `.exe`) and `SHA256SUMS-windows.txt` to the GitHub release - [ ] Run the installed app, pair a real device, confirm `vault-backend.log` has the expected boot lines +- [ ] Add an emulator without dropping a DLL; confirm it boots and reports firmware 7.16.0 +- [ ] On the emulator, run certified ETH→SOL ClearSign; labelled review appears and no Advanced Mode prompt is shown diff --git a/docs/certs/solana-scope-501-certificate.json b/docs/certs/solana-scope-501-certificate.json new file mode 100644 index 00000000..3be74b51 --- /dev/null +++ b/docs/certs/solana-scope-501-certificate.json @@ -0,0 +1 @@ +{"format":"keepkey-clearsign-delegate-cert-v1","certificateHex":"0101000001f56c68c8804b6565704b6579205661756c74000000000000000000000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea02b2dc9f48abcd2e46d4850cfa2753fac6068a45747a32a4a39f249af72b55370f3491913b7fb9a80207d619b3b4fca6750fc1fdc790da5562b42a351e12cde3c0f084056a24ca8d1bf2c36b5","root":{"path":"m/44'/60'/0'/0/0","publicKeyHex":"02de9231b2094433235532fb1932e324a2c7304195e12e610c675cccbbd606dae7","address":"0x4f55376c50edc6DE9E5f96A67556FbEeA8339f7a"},"issuedFor":{"alias":"KeepKey Vault","fingerprint":"a9531b9d","publicKeyHex":"0342f5f9704494b3f9bd72295eecaf29d783d23ea02b2dc9f48abcd2e46d4850cf"},"domainSeparatorHex":"8839401f8d0112b4348770ddace152e96fc5e5081aefeed6b5d8bef0d6ecdf66","messageHashHex":"1ea9bb5da542f83155e8902caacbdaab14b4cf116c078f597264f50d317a9a56","signingDigestHex":"9ccf4f4a81a9ae000a8e3ecc9b4f5f9e2ed37bec01e2061715307eee8b8c52d9"} \ No newline at end of file diff --git a/docs/emulator-release-sop.md b/docs/emulator-release-sop.md new file mode 100644 index 00000000..cad6711d --- /dev/null +++ b/docs/emulator-release-sop.md @@ -0,0 +1,105 @@ +# Bundled Emulator Release SOP + +The Vault release carries a certified KeepKey emulator library. Users must not +need to install `libkkemu` separately. Emulator artifact provenance and package +contents are blocking Vault release gates. + +This SOP does **not** gate, select, or modify a hardware firmware release. In +particular, never inspect or change the `modules/keepkey-firmware` gitlink to +make this gate pass. + +## Release identity + +For the current Vault release train, the approved emulator release is +`7.16.0`. The complete artifact set is: + +- `libkkemu.dylib` — universal macOS `arm64` + `x86_64` +- `libkkemu.dll` — Windows `x86_64` +- CI handoff name: `emulator-build-input-libkkemu-7.16.0-win-x64.dll` + +The expected emulator release must be declared by the Vault release branch and +must agree with the CI artifact name and the runtime version reported by the +emulator. The firmware submodule version is not a source of truth for this +decision. + +The controlling machine-readable record is +`projects/keepkey-vault/emulator-bundle/manifest.json`. It pins the certified +source run, artifact ID/digest, platform filenames, library hashes, +architectures, and required ABI symbols. + +## Non-negotiable boundary + +- Do not run firmware pinning, branch, cleanliness, behind/ahead, or version + reconciliation as part of a Vault release. +- Do not checkout, reset, clean, merge, fetch, or re-pin + `modules/keepkey-firmware`. +- If the approved emulator artifacts are absent, have unknown provenance, or + fail verification, stop the Vault release and repair the emulator artifact + intake/build workflow separately. Do not repair it by changing the firmware + gitlink. + +## Pre-build artifact gate + +Before packaging: + +1. Identify the successful certified emulator-artifact CI run and record its + run ID, commit SHA, artifact ID, and artifact digest in the manifest. The + Vault release commit may be later; the staged bytes must match the immutable + certified artifact recorded in the release branch. +2. Obtain both platform libraries from that same artifact. Do not + mix artifacts from different commits or runs. +3. Record SHA-256 for the original artifacts and the staged copies. They must + match byte-for-byte until platform signing intentionally changes them. +4. Stage: + - `projects/keepkey-vault/emulator-bundle/libkkemu.dylib` + - `projects/keepkey-vault/emulator-bundle/libkkemu.dll` +5. Verify the macOS library contains `arm64` and `x86_64`. +6. Verify both libraries export all 12 Vault FFI functions: + `kkemu_init`, `kkemu_shutdown`, `kkemu_write`, `kkemu_read`, `kkemu_poll`, + `kkemu_is_running`, `kkemu_pop_frame`, `kkemu_start`, `kkemu_stop`, + `kkemu_lock`, `kkemu_unlock`, and `kkemu_trylock`. +7. Verify the Windows DLL imports only Windows system/UCRT DLLs and needs no + MinGW runtime sidecars. +8. Require CI's ClearSign-root/configuration audit to be green for that exact + artifact run. + +Use `scripts/stage-certified-emulator.sh ` to extract, +stage, and verify both libraries. `make preflight` reruns +`scripts/verify-certified-emulator.mjs`; it never reads the firmware gitlink. + +Any failure is a hard stop for the Vault release. + +## Packaged-artifact gate + +After every platform build: + +1. Extract or mount the artifact actually intended for publication. +2. Confirm the bundled library exists at: + - macOS: `Contents/Resources/app/emulator/libkkemu.dylib` + - Windows: `Resources/app/emulator/libkkemu.dll` +3. Confirm the packaged library hash matches the staged library before signing, + or record and verify the expected post-signing hash when signing changes it. +4. Repeat the check for both macOS auto-update archives (`arm64` and `x64`), not + only the DMGs. + +Presence in a build directory is not evidence that the published app contains +the emulator. + +## Runtime smoke gate + +Smoke-test the packaged app with all user-installed emulator overrides removed +or moved aside so the bundled library is necessarily selected: + +- Add an emulator without installing a library and confirm it reports emulator + release `7.16.0`. +- Create or recover a wallet and confirm OLED preview updates. +- Stop and reopen the emulator; encrypted flash must persist. +- Run a certified ETH→SOL ClearSign swap with labelled review and no Advanced + Mode prompt. +- Run three consecutive signing operations to cover poll-thread/lock lifecycle. +- On Windows, close/reopen the installed app and confirm a fresh backend log + session starts. + +User-installed libraries remain a development override only. An override makes +the release smoke result invalid because it does not exercise the embedded +artifact. diff --git a/docs/submodule-pinning-sop.md b/docs/submodule-pinning-sop.md index c2f1f4e9..11df73b5 100644 --- a/docs/submodule-pinning-sop.md +++ b/docs/submodule-pinning-sop.md @@ -7,9 +7,15 @@ 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. +`modules/keepkey-firmware` is intentionally not a Vault release gate. Do not +inspect, clean, checkout, reset, fetch, or re-pin it while preparing a Vault +release. Its branch, version, nested submodules, CI state, and gitlink drift are +outside the desktop release decision. + +The emulator shipped inside Vault is a separate release artifact gate. A Vault +release must contain the approved `libkkemu` artifact set, but that requirement +never authorizes changing the firmware gitlink. See +[`docs/emulator-release-sop.md`](./emulator-release-sop.md). ## Submodule Inventory @@ -17,14 +23,14 @@ on its branch, nested submodules, or CI state. |--------|------|-----------------|---------| | **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** | +| **device-protocol** | `BitHighlander/device-protocol` | `master` | Canonical Vault protocol fork and published `@bithighlander/device-protocol` package | | **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. | +| **keepkey-firmware** | `BitHighlander/keepkey-firmware` | Firmware source and development fixtures. Never reconcile or re-pin it during a Vault release. The separately certified bundled emulator artifacts have their own gate. | ## Pre-Release Pinning Checklist @@ -50,7 +56,7 @@ done ``` **All release-gated modules must show `[OK]` and `dirty=0` before cutting a -release branch. Ignore `modules/keepkey-firmware` for Vault packaging.** +release branch. Do not include `modules/keepkey-firmware` in this check.** ```bash # 4. Verify CI is green on every pinned commit @@ -59,7 +65,7 @@ echo "=== CI Status on Pinned Commits ===" declare -A REPOS=( ["modules/hdwallet"]="keepkey/hdwallet" ["modules/proto-tx-builder"]="BitHighlander/proto-tx-builder" - ["modules/device-protocol"]="keepkey/device-protocol" + ["modules/device-protocol"]="BitHighlander/device-protocol" ["modules/electrobun"]="blackboardsh/electrobun" ) for mod in "${!REPOS[@]}"; do @@ -80,8 +86,8 @@ done - ✅ ALL GREEN: proceed - ⏳ PENDING: wait for completion - ❌ FAILED: STOP — do not release with failing CI on any submodule -- ⚠️ NO CI: acceptable for repos without workflows (device-protocol), but - flag it in release notes +- ⚠️ NO CI: acceptable only for repos documented without workflows; it is not + acceptable for `BitHighlander/device-protocol` **Current CI coverage:** @@ -89,7 +95,7 @@ done |------|-----------|-------| | keepkey/hdwallet | CI (build matrix) | Must pass | | BitHighlander/proto-tx-builder | Build & Test | Must pass | -| keepkey/device-protocol | **None** | No CI — validate manually (lib/ build) | +| BitHighlander/device-protocol | Build & Publish + Protocol CI | Both validation jobs must pass; the exact fork commit must be published | | blackboardsh/electrobun | Build and Release + CEF Check | Build must pass; CEF is informational | ## Per-Module Rules @@ -109,19 +115,31 @@ done ### device-protocol (`master`) -- **Must be synced to upstream `keepkey/device-protocol` master before release** -- The protocol version must match the firmware version being targeted -- If a new firmware release adds proto messages, those must be merged to master first +- **Must come from `BitHighlander/device-protocol` fork `master`** +- **Never reconcile, merge, publish, or gate against `keepkey/device-protocol`** +- The generated protocol library must satisfy the Vault runtime contract +- Required protocol changes must be merged to the fork `master` first +- The package must be published as `@bithighlander/device-protocol` from the exact pinned fork commit - The `lib/` directory is gitignored — must be pre-built before vault builds -- 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 +- Verify the remote first: `git submodule sync -- modules/device-protocol && git -C modules/device-protocol remote get-url origin` +- The remote must be `https://github.com/BitHighlander/device-protocol` +- Verify the fork branch: `cd modules/device-protocol && git fetch origin master && git log --oneline origin/master..HEAD` (should be empty) +- Verify publication: fork tag `v` must resolve to the pinned commit, + the registry repository must be the BitHighlander fork, and a local dry-run + pack from the pinned commit must match the registry `dist.integrity`. If npm + records `gitHead`, it must also equal the pinned commit. ### keepkey-firmware (ignored for Vault releases) - 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. +- Do not run firmware status, version, branch, behind/ahead, CI, or recursive + nested-submodule checks during Vault release prep. +- Never checkout, reset, clean, fetch, merge, or re-pin this submodule to make a + Vault release pass. +- A missing or incorrect bundled emulator stops the Vault release at the + emulator artifact gate; it is not repaired by changing this gitlink. +- Firmware changes and emulator artifact production happen in their own + workflow, outside the Vault release procedure. ### electrobun (`main`) @@ -139,7 +157,7 @@ derivation for months because the pin wasn't updated. behind upstream:** ```bash -echo "=== Commits behind upstream ===" +echo "=== Commits behind canonical branches ===" 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" @@ -197,12 +215,13 @@ created until all release-gated submodules show `[OK]` and `dirty=0`. 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) +2. **Run the canonical-branch-behind check** — review and pull any bug fixes +3. Verify `device-protocol` is on `BitHighlander/device-protocol` fork master and that the exact commit is published as `@bithighlander/device-protocol` 4. Verify `electrobun` is on `main` HEAD 5. Verify `hdwallet` is on `master` with lodash/rxjs removal -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 +6. Do not inspect or modify `modules/keepkey-firmware`. +7. Run the bundled emulator artifact gate in `docs/emulator-release-sop.md`. +8. Run `make build-stable` to confirm build succeeds with current runtime pins. ### Post-Release @@ -220,8 +239,5 @@ After release is published: | 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 | +`modules/keepkey-firmware` is intentionally omitted. Its pin is not inventory +for a Vault release and must not be changed by this SOP. diff --git a/modules/device-protocol b/modules/device-protocol index fbb483f1..bee6cdd6 160000 --- a/modules/device-protocol +++ b/modules/device-protocol @@ -1 +1 @@ -Subproject commit fbb483f1cadc6394d51566ca4be0cfe6daa40000 +Subproject commit bee6cdd624905d6b5bcc54a05fc3deb24242483d diff --git a/modules/hdwallet b/modules/hdwallet index 87553b99..529353ac 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 87553b99d51139ad1649614df58de25b5d355b40 +Subproject commit 529353ac45c2b51b361ec746bdb7c05f0f47b9ec diff --git a/modules/keepkey-firmware b/modules/keepkey-firmware index 292786e3..65e1659e 160000 --- a/modules/keepkey-firmware +++ b/modules/keepkey-firmware @@ -1 +1 @@ -Subproject commit 292786e3fd936a8c4d4a971af2dc37855e5e1186 +Subproject commit 65e1659e0f92c9b162508491dff1f1b00af1b9ed diff --git a/projects/keepkey-sdk/tests/_clearsign.js b/projects/keepkey-sdk/tests/_clearsign.js index 98ad5c01..2b92fc32 100644 --- a/projects/keepkey-sdk/tests/_clearsign.js +++ b/projects/keepkey-sdk/tests/_clearsign.js @@ -57,6 +57,18 @@ function sighashLegacy(to, value, data, chainId) { return keccak_256(rlpList(items)) } +// Keep the frozen 51-flow Python parity corpus intact and layer independently +// sourced 2026 SDK fixtures on top. Their txHash is derived here with the same +// implementation used by buildFlowBlob, then independently rechecked by the +// vendored offline gate. +const VENDORED_FLOWS = require('./fixtures/evm-clearsign-vendored') +for (const flow of Object.values(VENDORED_FLOWS)) { + flow.txHash = Buffer.from(sighashLegacy( + hex(flow.to), BigInt(flow.value), hex(flow.calldata), flow.chainId, + )).toString('hex') +} +const ALL_FLOWS = { ...GOLDEN.flows, ...VENDORED_FLOWS } + function serializeMetadata(f) { const name = new TextEncoder().encode(f.methodName) const parts = [Uint8Array.from([0x01]), be(f.chainId, 4), f.contractAddress, f.selector, f.txHash, @@ -82,7 +94,7 @@ function signBlob(payload, priv) { * /eth/sign-transaction; the device recomputes the same sighash the blob binds. */ function buildFlowBlob(key) { - const flow = GOLDEN.flows[key] + const flow = ALL_FLOWS[key] if (!flow) throw new Error(`unknown flow: ${key}`) const to = hex(flow.to), data = hex(flow.calldata) const jsHash = sighashLegacy(to, flow.value, data, flow.chainId) @@ -113,5 +125,6 @@ function buildFlowBlob(key) { module.exports = { GOLDEN, buildFlowBlob, sighashLegacy, serializeMetadata, signBlob, + ALL_FLOWS, VENDORED_FLOWS, CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID, TEST_PRIV, } diff --git a/projects/keepkey-sdk/tests/clearsign-vendored-offline.js b/projects/keepkey-sdk/tests/clearsign-vendored-offline.js new file mode 100644 index 00000000..352ab68b --- /dev/null +++ b/projects/keepkey-sdk/tests/clearsign-vendored-offline.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Offline acceptance for the separately sourced 2026 ClearSign expansion. + * No Vault, device, network, or production signer is required. + */ +const { sha256 } = require('@noble/hashes/sha256') +const { secp256k1 } = require('@noble/curves/secp256k1') +const { + GOLDEN, ALL_FLOWS, VENDORED_FLOWS, buildFlowBlob, sighashLegacy, TEST_PRIV, +} = require('./_clearsign') + +function fail(message) { + console.error(`FAIL: ${message}`) + process.exit(1) +} + +function main() { + const keys = Object.keys(VENDORED_FLOWS) + if (keys.length !== 8) fail(`expected 8 vendored flows, got ${keys.length}`) + + const pubkey = secp256k1.getPublicKey(TEST_PRIV, true) + if (Buffer.from(pubkey).toString('hex') !== GOLDEN.testPubKey) fail('test signer pubkey drifted') + + for (const key of keys) { + if (GOLDEN.flows[key]) fail(`${key}: collides with frozen Python corpus`) + const flow = ALL_FLOWS[key] + if (!flow.sources?.length || flow.sources.some((source) => !source.startsWith('https://'))) { + fail(`${key}: missing HTTPS provenance`) + } + if (flow.calldata.slice(0, 8) !== flow.selector) fail(`${key}: selector/calldata mismatch`) + + const expectedHash = Buffer.from(sighashLegacy( + Buffer.from(flow.to, 'hex'), BigInt(flow.value), Buffer.from(flow.calldata, 'hex'), flow.chainId, + )).toString('hex') + if (flow.txHash !== expectedHash) fail(`${key}: transaction binding mismatch`) + + const built = buildFlowBlob(key) + const blob = Buffer.from(built.blobHex, 'hex') + const payload = blob.subarray(0, -65) + const compactSignature = blob.subarray(-65, -1) + if (!secp256k1.verify(compactSignature, sha256(payload), pubkey, { lowS: false })) { + fail(`${key}: metadata signature does not verify`) + } + if (built.tx.data.slice(2) !== flow.calldata) fail(`${key}: signed tx calldata drifted`) + console.log(` ✅ ${key} — ${flow.method} ${blob.length}B`) + } + + console.log(`\nvendored ClearSign offline: ${keys.length}/${keys.length} ABI/binding/signature/provenance checks passed`) +} + +main() diff --git a/projects/keepkey-sdk/tests/evm-clearsign/certified-relay-bridge-deposit.js b/projects/keepkey-sdk/tests/evm-clearsign/certified-relay-bridge-deposit.js new file mode 100644 index 00000000..1983b173 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-clearsign/certified-relay-bridge-deposit.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Production-shaped 7.16 acceptance: fetch a root-certified EVM schema from + * the deployed ClearSign service and sign without loading a runtime signer or + * enabling Advanced Mode. + * + * The service sees only chain, contract, selector, and calldata length. The + * KeepKey verifies the certificate and delegate signature, then decodes the + * actual depositor and orderId from the transaction it signs. + */ +const { run, ETH_PATH } = require('../_helpers') + +const SERVICE = process.env.CLEARSIGN_SERVICE_URL || + 'https://keepkey-clearsign.bithighlander.workers.dev' +const CONTRACT = '0x4cd00e387622c35bddb9b4c962c136462338bc31' +const SELECTOR = '0x49290c1c' +const DEPOSITOR = '0x742d35cc6634c0532950a20547b231011e30c8e7' +const ORDER_ID = `0x${'ab'.repeat(32)}` +const DATA = SELECTOR + + DEPOSITOR.slice(2).padStart(64, '0') + + ORDER_ID.slice(2) + +run('7.16 certified Relay bridgeDeposit (Advanced Mode off)', async (getSdk, assert) => { + const response = await fetch(`${SERVICE}/v1/evm/schema`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chainId: 1, + contract: CONTRACT, + selector: SELECTOR, + calldataLength: (DATA.length - 2) / 2, + }), + }) + const envelope = await response.json() + if (!response.ok) throw new Error(`ClearSign service ${response.status}: ${envelope.error || 'unknown error'}`) + + assert('service returned VERIFIED metadata', envelope.classification === 'VERIFIED') + assert('envelope is certified v3', envelope.version === 3 && envelope.signedPayload?.slice(0, 4) === '0x03') + assert('reserved delegate keyId is 0x80', envelope.keyId === 0x80) + assert('reviewed alpha signer fingerprint matches', envelope.fingerprint === 'a9531b9d') + assert('response is bound to the requested shape', + envelope.chainId === 1 && + envelope.contract.toLowerCase() === CONTRACT && + envelope.selector.toLowerCase() === SELECTOR) + + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + console.log(` Device ETH address: ${address}`) + console.log(' No LoadClearsignSigner call was made.') + console.log(' Expect: Authenticated by KeepKey / bridgeDeposit / depositor / orderId.') + + const result = await sdk.eth.ethSignTransaction({ + to: CONTRACT, + data: DATA, + value: '0x0', + nonce: '0x0', + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + addressNList: ETH_PATH, + txMetadata: { signedPayload: envelope.signedPayload, keyId: envelope.keyId }, + }) + assert('certified Relay transaction signed', !!(result && (result.serializedTx || result.r))) +}) diff --git a/projects/keepkey-sdk/tests/evm-clearsign/loadsigner-sign-flows.js b/projects/keepkey-sdk/tests/evm-clearsign/loadsigner-sign-flows.js index 2bbb7958..b86cc04e 100644 --- a/projects/keepkey-sdk/tests/evm-clearsign/loadsigner-sign-flows.js +++ b/projects/keepkey-sdk/tests/evm-clearsign/loadsigner-sign-flows.js @@ -3,7 +3,7 @@ * * 1. Loads the CI test signer (pubkey == firmware slot 3) at runtime via the new * POST /eth/clearsign/load-signer route → DEVICE CONFIRM (trust screen). - * 2. For each flagship flow: builds the metadata blob bound to the tx's real + * 2. For each selected flow: builds the metadata blob bound to the tx's real * sighash (tests/_clearsign.js, byte-parity-proven offline) and signs via * /eth/sign-transaction with txMetadata → DEVICE CONFIRM (clear-sign pages). * @@ -11,20 +11,58 @@ * with the new hdwallet loadClearsignSigner + route. Sign-only, no broadcast, * no wipe. The signer is RAM-only — reload if the device reboots. * - * Run: KEEPKEY_API_KEY=… node tests/evm-clearsign/loadsigner-sign-flows.js + * The frozen 51-flow Python-parity corpus plus separately sourced 2026 SDK + * additions are addressable without turning one run into a + * multi-hour approval ceremony. Select a comma-separated list, or page through + * it with CLEARSIGN_START + CLEARSIGN_LIMIT. The default is the first five. + * + * List only: CLEARSIGN_LIST=1 node tests/evm-clearsign/loadsigner-sign-flows.js + * Run batch: KEEPKEY_API_KEY=… CLEARSIGN_START=0 CLEARSIGN_LIMIT=5 \ + * node tests/evm-clearsign/loadsigner-sign-flows.js + * Named: KEEPKEY_API_KEY=… CLEARSIGN_FLOW=aave-v3-supply,erc20-transfer \ + * node tests/evm-clearsign/loadsigner-sign-flows.js */ const { run, ETH_PATH } = require('../_helpers') -const { buildFlowBlob, CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID } = require('../_clearsign') +const { ALL_FLOWS, buildFlowBlob, CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID } = require('../_clearsign') + +const FLOW_KEYS = Object.keys(ALL_FLOWS).sort() + +function integerEnv(name, fallback) { + const raw = process.env[name] + if (raw === undefined || raw === '') return fallback + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`) + return value +} + +function selectedFlows() { + const named = (process.env.CLEARSIGN_FLOW || '').split(',').map(value => value.trim()).filter(Boolean) + if (named.length) { + const unknown = named.filter(key => !ALL_FLOWS[key]) + if (unknown.length) throw new Error(`Unknown CLEARSIGN_FLOW: ${unknown.join(', ')}`) + return named + } + const start = integerEnv('CLEARSIGN_START', 0) + const limit = integerEnv('CLEARSIGN_LIMIT', 5) + return FLOW_KEYS.slice(start, start + limit) +} + +if (process.env.CLEARSIGN_LIST === '1') { + console.log(`Runtime-signer ClearSign corpus (${FLOW_KEYS.length} flows):`) + FLOW_KEYS.forEach((key, index) => console.log(`${String(index).padStart(2, ' ')} ${key}`)) + process.exit(0) +} -// Flagship tranche: STRING + TOKEN_AMOUNT + ADDRESS (aave), token transfer, -// and an UNLIMITED approval render. Expand to the full 51 once green. -const FLOWS = ['aave-v3-supply', 'erc20-transfer', 'erc20-approve-unlimited'] +const FLOWS = selectedFlows() +if (!FLOWS.length) throw new Error('Selected ClearSign batch is empty') -run('clear-sign: load CI signer + sign flagship flows', async (getSdk, assert) => { +run(`clear-sign: load CI signer + sign ${FLOWS.length}/${FLOW_KEYS.length} flows`, async (getSdk, assert) => { const sdk = await getSdk() const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) console.log(` Device ETH address: ${address}`) + console.log(` Batch: ${FLOWS.join(', ')}`) + console.log(' Lane: legacy transaction-bound metadata; raw review remains mandatory.') console.log(`\n Loading CI signer into slot ${TEST_KEY_ID}, alias "${CI_SIGNER_ALIAS}"`) console.log(` pubkey ${CI_TEST_PUBKEY}`) @@ -36,6 +74,7 @@ run('clear-sign: load CI signer + sign flagship flows', async (getSdk, assert) = const { tx, blobHex, keyId, flow } = buildFlowBlob(key) console.log(`\n [${key}] ${flow.method} to=${tx.to}`) console.log(` args: ${flow.args.map(a => a.name).join(', ')}`) + if (flow.sources?.length) console.log(` source: ${flow.sources[0]}`) console.log(' >>> APPROVE the clear-sign pages on device <<<') const result = await sdk.eth.ethSignTransaction({ ...tx, diff --git a/projects/keepkey-sdk/tests/evm-clearsign/provider-key-schema-flow.js b/projects/keepkey-sdk/tests/evm-clearsign/provider-key-schema-flow.js new file mode 100644 index 00000000..465adc23 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-clearsign/provider-key-schema-flow.js @@ -0,0 +1,115 @@ +/** + * evm-clearsign/provider-key-schema-flow.js — the whole chain, end to end. + * + * ceremony key → offline catalog build → read-only server → device clear-signs. + * + * Deliberately consumes only what the server publishes. No key file, no local + * schema building: if this passes, the bytes a real client would fetch are the + * bytes the device decoded. Building the schema in-process would have proven + * the serializer and nothing about the catalog. + * + * 1. GET /signer — the identity the device's trust screen must match. + * 2. GET /catalog/… — pull the USDC transfer schema and verify its + * signature offline, before spending any device press. + * 3. Load the signer — DEVICE CONFIRM. The fingerprint on screen MUST equal + * the one printed here; only a human can check that, + * the device never reports it back over the wire. + * 4. Sign a transfer — DEVICE CONFIRM decoded recipient/amount, not raw hex. + * + * Step 4 is what makes step 3 more than theatre: the device only decodes if the + * catalog's signature verifies against the key it was told to trust. + * + * Needs firmware 7.15.0-rc4+ (METADATA_VERSION_SCHEMA) and the catalog server + * (cd projects/keepkey-clearsign-server && make serve). + * + * Run: KEEPKEY_API_KEY=… node tests/evm-clearsign/provider-key-schema-flow.js + */ +const { sha256 } = require('@noble/hashes/sha256') +const { secp256k1 } = require('@noble/curves/secp256k1') +const { run, ETH_PATH, erc20Transfer } = require('../_helpers') + +const CATALOG = process.env.CLEARSIGN_CATALOG || 'http://localhost:1647' + +const USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +const TRANSFER = '0xa9059cbb' +const RECIPIENT = '0x1d0e8e5c8f4a3f4b0c9a2e6d7b8c1a3f5e2d4c6b' +const AMOUNT = 1000000000n // 1000 USDC at 6 decimals + +async function getJson(path) { + const response = await fetch(`${CATALOG}${path}`) + if (!response.ok) throw new Error(`GET ${path} → ${response.status}`) + return response.json() +} + +run('clear-sign: catalog schema signs on device', async (getSdk, assert) => { + let signer, chain + try { + signer = await getJson('/signer') + chain = await getJson('/catalog/eip155-1.json') + } catch (cause) { + // Not a failure: run-all.js has no catalog server. Say why it did nothing + // rather than passing silently. + console.log(` SKIPPED — no catalog at ${CATALOG} (${cause.message}).`) + console.log(' Start it: cd projects/keepkey-clearsign-server && make serve\n') + return + } + + console.log(` Catalog: ${CATALOG} · signer ${signer.alias} · slot ${signer.keyId}`) + console.log(` eip155:1 — ${Object.keys(chain.entries).length} schemas, built ${chain.builtAt}`) + + const fingerprint = Buffer.from(sha256(Buffer.from(signer.publicKeyHex, 'hex'))).toString('hex').slice(0, 8) + assert('served fingerprint matches its public key', fingerprint === signer.fingerprint) + + const b64 = chain.entries[`${USDC}:${TRANSFER}`] + assert('catalog has the USDC transfer schema', typeof b64 === 'string') + const blob = Buffer.from(b64, 'base64') + assert('blob is v2 (METADATA_VERSION_SCHEMA)', blob[0] === 0x02) + + // The last body byte is key_id; it must equal the slot we load, or the device + // resolves the wrong pubkey and rejects. + const body = blob.subarray(0, blob.length - 65) + assert('blob key_id matches the signer slot', body[body.length - 1] === signer.keyId) + + // Verify offline first — a corrupted or substituted entry should cost zero + // device presses to catch. lowS:false matches the reference signer. + const signature = secp256k1.Signature.fromCompact(blob.subarray(body.length, body.length + 64)) + const verified = secp256k1.verify(signature, sha256(body), Buffer.from(signer.publicKeyHex, 'hex'), { lowS: false }) + assert('catalog signature verifies under the served public key', verified) + + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + console.log(` Device ETH address: ${address}`) + + console.log(`\n Loading "${signer.alias}" into slot ${signer.keyId}`) + console.log(` pubkey ${signer.publicKeyHex}`) + console.log(`\n >>> The device must show fingerprint ${fingerprint.toUpperCase()} <<<`) + console.log(' >>> Anything else: REJECT — you do not know which key you are trusting <<<\n') + const load = await sdk.eth.loadClearsignSigner({ + keyId: signer.keyId, + pubkey: signer.publicKeyHex, + alias: signer.alias, + }) + assert('signer loaded (device confirmed)', !!load && load.ok === true) + + // transfer(address,uint256) — 4 + 2*32 bytes. The device requires exactly + // this length or v2 decode fails and it falls back to blind-signing. + const data = '0x' + erc20Transfer(RECIPIENT, AMOUNT).replace(/^0x/, '') + assert('calldata is 4 + 32*num_args bytes', (data.length - 2) / 2 === 68) + + console.log(`\n Signing 1000 USDC → ${RECIPIENT}`) + console.log(' >>> APPROVE the clear-sign pages — recipient and amount, no raw hex <<<') + const result = await sdk.eth.ethSignTransaction({ + to: USDC, + value: '0x0', + data, + nonce: '0x0', + gasLimit: '0x' + (250000).toString(16), + gasPrice: '0x' + (20000000000).toString(16), + chainId: 1, + addressNList: ETH_PATH, + // The catalog stores base64 (compact, matches the reference payload); the + // vault's REST layer takes hex. Convert at the edge, as relay-v2 does. + txMetadata: { signedPayload: blob.toString('hex'), keyId: signer.keyId }, + }) + assert('device signed under the catalog identity', !!(result && (result.serializedTx || result.r))) +}) diff --git a/projects/keepkey-sdk/tests/evm-clearsign/provider-live-sign-flow.js b/projects/keepkey-sdk/tests/evm-clearsign/provider-live-sign-flow.js new file mode 100644 index 00000000..d1f7be4b --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-clearsign/provider-live-sign-flow.js @@ -0,0 +1,115 @@ +/** + * evm-clearsign/provider-live-sign-flow.js — the live provider path, on device. + * + * A provider hosts a signing server holding its own key. The wallet sends the + * unsigned transaction; the server decodes it, attests what it derived, and + * returns a blob bound to that transaction's real sighash. The device shows the + * decode under the provider's identity. + * + * 1. GET /signer — the identity the device's trust screen must match. + * 2. POST /sign — the exact tx we are about to sign. The server derives the + * selector, the values and the sighash from those bytes. + * 3. Load signer — DEVICE CONFIRM, fingerprint must match. + * 4. Sign — DEVICE CONFIRM decoded recipient/amount, not raw hex. + * + * Also checks the two refusals that matter more than the happy path: an + * uncurated contract and calldata with trailing bytes must be DECLINED, not + * stamped. A signer that attests what it cannot decode is worse than no signer, + * because a matched blob REPLACES the device's raw-data screen. + * + * Needs firmware 7.15.0-rc4+, AdvancedMode on, and the provider server + * (cd projects/keepkey-clearsign-server && make serve). + * + * Run: KEEPKEY_API_KEY=… node tests/evm-clearsign/provider-live-sign-flow.js + */ +const { run, ETH_PATH, erc20Transfer } = require('../_helpers') + +const PROVIDER = process.env.CLEARSIGN_PROVIDER || 'http://localhost:1647' + +const USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +const UNCURATED = '0x1111111254eeb25477b68fb85ed929f73a960582' +const RECIPIENT = '0x742d35cc6634c0532950a20547b231011e30c8e7' +const AMOUNT = 1000000n // 1.0 USDC + +// The exact transaction the device will sign. Every field feeds the sighash, so +// any drift between what we send the signer and what we send the device makes +// the device reject the blob and blind-sign. +const TX = { + chainId: 1, + to: USDC, + data: '0x' + erc20Transfer(RECIPIENT, AMOUNT).replace(/^0x/, ''), + value: '0x0', + nonce: '0x0', + gasLimit: '0x3d090', + gasPrice: '0x4a817c800', +} + +async function post(path, body) { + const response = await fetch(`${PROVIDER}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return { status: response.status, body: await response.json() } +} + +run('clear-sign: live provider attestation on device', async (getSdk, assert) => { + let signer + try { + const response = await fetch(`${PROVIDER}/signer`) + if (!response.ok) throw new Error(`${response.status}`) + signer = await response.json() + } catch (cause) { + console.log(` SKIPPED — no provider server at ${PROVIDER} (${cause.message}).`) + console.log(' Start it: cd projects/keepkey-clearsign-server && make serve\n') + return + } + + console.log(` Provider: ${signer.alias} · ${signer.fingerprint} · slot ${signer.keyId}`) + + // Refusals first — they cost no device presses and they are the property that + // makes the happy path safe. + const uncurated = await post('/sign', { ...TX, to: UNCURATED }) + assert('declines an uncurated contract', uncurated.status === 422 && uncurated.body.classification === 'OPAQUE') + + const trailing = await post('/sign', { ...TX, data: TX.data + 'dead' }) + assert('declines calldata with trailing bytes', trailing.status === 422) + + const noFee = await post('/sign', { ...TX, gasPrice: undefined }) + assert('declines a tx with no fee model', noFee.status >= 400) + + const attested = await post('/sign', TX) + assert('attests the curated transfer', attested.status === 200) + const { signedPayload, keyId, txHash, decoded } = attested.body + console.log(` Attested: ${decoded.contract} · ${decoded.method}(${decoded.args.join(', ')})`) + console.log(` txHash ${txHash}`) + + assert('blob is v1 (METADATA_VERSION_LEGACY)', signedPayload.slice(0, 2) === '01') + assert('blob key_id matches the signer slot', keyId === signer.keyId) + // The tx_hash the server bound is inside the blob it signed; if it were not + // the digest of the tx below, the device would refuse it. + assert('blob carries the derived tx hash', signedPayload.includes(txHash)) + + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + console.log(` Device ETH address: ${address}`) + + console.log(`\n Loading "${signer.alias}" into slot ${signer.keyId}`) + console.log(`\n >>> The device must show fingerprint ${signer.fingerprint.toUpperCase()} <<<`) + console.log(' >>> Anything else: REJECT — you do not know which key you are trusting <<<\n') + const load = await sdk.eth.loadClearsignSigner({ + keyId: signer.keyId, + pubkey: signer.publicKeyHex, + alias: signer.alias, + }) + assert('signer loaded (device confirmed)', !!load && load.ok === true) + + console.log(`\n Signing 1.0 USDC → ${RECIPIENT}`) + console.log(' >>> APPROVE the clear-sign pages — USD Coin, recipient, 1 USDC, no raw hex <<<') + const result = await sdk.eth.ethSignTransaction({ + ...TX, + addressNList: ETH_PATH, + txMetadata: { signedPayload, keyId }, + }) + assert('device signed under the provider identity', !!(result && (result.serializedTx || result.r))) +}) diff --git a/projects/keepkey-sdk/tests/evm-eip712/matrix.js b/projects/keepkey-sdk/tests/evm-eip712/matrix.js new file mode 100644 index 00000000..2e470aa3 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-eip712/matrix.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Interactive structured EIP-712 acceptance matrix. + * + * Firmware 7.16 should walk and display every field with Advanced Mode OFF. + * Signing is local and never broadcasts. Keep batches small: each case has + * several device pages and a signature is returned only after final approval. + * + * List: EIP712_LIST=1 node tests/evm-eip712/matrix.js + * One: KEEPKEY_API_KEY=… EIP712_FLOW=permit2-single node tests/evm-eip712/matrix.js + * Page: KEEPKEY_API_KEY=… EIP712_START=0 EIP712_LIMIT=2 node tests/evm-eip712/matrix.js + */ +const { run, ETH_PATH } = require('../_helpers') +const { fixtures } = require('../fixtures/eip712-matrix') + +const ALL_FLOWS = Object.keys(fixtures) + +function integerEnv(name, fallback) { + const raw = process.env[name] + if (raw === undefined || raw === '') return fallback + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`) + return value +} + +function selectedFlows() { + const named = (process.env.EIP712_FLOW || '').split(',').map(value => value.trim()).filter(Boolean) + if (named.length) { + const unknown = named.filter(key => !fixtures[key]) + if (unknown.length) throw new Error(`Unknown EIP712_FLOW: ${unknown.join(', ')}`) + return named + } + const start = integerEnv('EIP712_START', 0) + const limit = integerEnv('EIP712_LIMIT', 1) + return ALL_FLOWS.slice(start, start + limit) +} + +if (process.env.EIP712_LIST === '1') { + console.log(`Structured EIP-712 matrix (${ALL_FLOWS.length} flows):`) + ALL_FLOWS.forEach((key, index) => console.log(`${index} ${key} — ${fixtures[key].purpose}`)) + process.exit(0) +} + +const flows = selectedFlows() +if (!flows.length) throw new Error('Selected EIP-712 batch is empty') + +run(`structured EIP-712: sign ${flows.length}/${ALL_FLOWS.length} flows`, async (getSdk, assert) => { + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + console.log(` Signer: ${address}`) + console.log(' Acceptance: Advanced Mode OFF; exact domain/type/member/value pages; no blind-sign warning.') + + for (const key of flows) { + const fixture = fixtures[key] + console.log(`\n [${key}] ${fixture.purpose}`) + console.log(` Primary type: ${fixture.typedData.primaryType}`) + console.log(` Domain: ${JSON.stringify(fixture.typedData.domain)}`) + console.log(' >>> APPROVE every structured page, then the final signature <<<') + + const result = await sdk.eth.ethSignTypedData({ address, typedData: fixture.typedData }) + const signature = typeof result === 'string' ? result : result?.signature + assert(`[${key}] got a 65-byte signature`, typeof signature === 'string' && /^0x[0-9a-fA-F]{130}$/.test(signature)) + } +}) diff --git a/projects/keepkey-sdk/tests/fixtures/eip712-matrix.js b/projects/keepkey-sdk/tests/fixtures/eip712-matrix.js new file mode 100644 index 00000000..2508a75d --- /dev/null +++ b/projects/keepkey-sdk/tests/fixtures/eip712-matrix.js @@ -0,0 +1,177 @@ +/** Representative EIP-712 documents for structured device-review coverage. */ + +const ADDRESS = { + owner: '0x1111111111111111111111111111111111111111', + recipient: '0x2222222222222222222222222222222222222222', + spender: '0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD', + usdc: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + permit2: '0x000000000022D473030F116dDEE9F6B43aC78BA3', + dai: '0x6B175474E89094C44Da98b954EedeAC495271d0F', +} + +const domain = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +] + +const permitDetails = [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + { name: 'nonce', type: 'uint48' }, +] + +const fixtures = { + 'permit2-single': { + purpose: 'A bounded Uniswap Permit2 token allowance with a nested struct', + typedData: { + types: { + EIP712Domain: domain.filter(field => field.name !== 'version'), + PermitDetails: permitDetails, + PermitSingle: [ + { name: 'details', type: 'PermitDetails' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + }, + primaryType: 'PermitSingle', + domain: { name: 'Permit2', chainId: 1, verifyingContract: ADDRESS.permit2 }, + message: { + details: { token: ADDRESS.usdc, amount: '1000000000', expiration: '1893456000', nonce: '0' }, + spender: ADDRESS.spender, + sigDeadline: '1893456000', + }, + }, + }, + 'permit2-batch': { + purpose: 'Permit2 array traversal with two independently reviewed allowances', + typedData: { + types: { + EIP712Domain: domain.filter(field => field.name !== 'version'), + PermitDetails: permitDetails, + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + }, + primaryType: 'PermitBatch', + domain: { name: 'Permit2', chainId: 1, verifyingContract: ADDRESS.permit2 }, + message: { + details: [ + { token: ADDRESS.usdc, amount: '250000000', expiration: '1893456000', nonce: '1' }, + { token: ADDRESS.dai, amount: '500000000000000000000', expiration: '1893456000', nonce: '2' }, + ], + spender: ADDRESS.spender, + sigDeadline: '1893456000', + }, + }, + }, + 'erc2612-usdc-permit': { + purpose: 'ERC-2612 allowance with owner, spender, exact amount, nonce and deadline', + typedData: { + types: { + EIP712Domain: domain, + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + domain: { name: 'USD Coin', version: '2', chainId: 1, verifyingContract: ADDRESS.usdc }, + message: { + owner: ADDRESS.owner, + spender: ADDRESS.spender, + value: '1000000000', + nonce: '7', + deadline: '1893456000', + }, + }, + }, + 'dai-permit': { + purpose: 'DAI-style boolean allowance whose field names differ from ERC-2612', + typedData: { + types: { + EIP712Domain: domain, + Permit: [ + { name: 'holder', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'expiry', type: 'uint256' }, + { name: 'allowed', type: 'bool' }, + ], + }, + primaryType: 'Permit', + domain: { name: 'Dai Stablecoin', version: '1', chainId: 1, verifyingContract: ADDRESS.dai }, + message: { + holder: ADDRESS.owner, + spender: ADDRESS.spender, + nonce: '8', + expiry: '1893456000', + allowed: true, + }, + }, + }, + 'x402-transfer-authorization': { + purpose: 'Circle EIP-3009/x402 payment authorization with bytes32 nonce', + typedData: { + types: { + EIP712Domain: domain, + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + }, + primaryType: 'TransferWithAuthorization', + domain: { name: 'USD Coin', version: '2', chainId: 8453, verifyingContract: ADDRESS.usdc }, + message: { + from: ADDRESS.owner, + to: ADDRESS.recipient, + value: '1250000', + validAfter: '0', + validBefore: '1893456000', + nonce: '0x' + '42'.repeat(32), + }, + }, + }, + 'eip712-mail': { + purpose: 'Published EIP-712 nested-struct reference shape', + typedData: { + types: { + EIP712Domain: domain, + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + message: { + from: { name: 'Cow', wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' }, + to: { name: 'Bob', wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' }, + contents: 'Hello, Bob!', + }, + }, + }, +} + +module.exports = { fixtures } diff --git a/projects/keepkey-sdk/tests/fixtures/evm-clearsign-vendored.js b/projects/keepkey-sdk/tests/fixtures/evm-clearsign-vendored.js new file mode 100644 index 00000000..27cc5ccb --- /dev/null +++ b/projects/keepkey-sdk/tests/fixtures/evm-clearsign-vendored.js @@ -0,0 +1,271 @@ +/** + * 2026 ClearSign expansion researched from primary protocol sources. + * + * These fixtures deliberately live beside, rather than inside, + * clearsign-golden.json. The golden file is a frozen 51-flow Python parity + * artifact; changing it would make the existing "51/51 matches Python" claim + * false. `_clearsign.js` layers these fixtures onto that corpus and binds each + * one to the deterministic test transaction's real sighash. + * + * Calldata is ABI-encoded by ethers from the cited canonical function + * signature. Nothing below is hand-written selector/calldata hex. + */ +const ethersPackage = require('ethers') +const Interface = ethersPackage.Interface || ethersPackage.utils.Interface + +const ADDRESS = 1 +const STRING = 4 +const TOKEN_AMOUNT = 5 + +const RECIPIENT = '0x742d35cc6634c0532950a20547b231011e30c8e7' +const SPENDER = '0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD' +const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F' +const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +const WSTETH = '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0' + +function utf8Hex(value) { + return Buffer.from(value, 'utf8').toString('hex') +} + +function addressArg(name, value) { + return { name, format: ADDRESS, value: value.toLowerCase().replace(/^0x/, '') } +} + +function stringArg(name, value) { + return { name, format: STRING, value: utf8Hex(value) } +} + +function tokenAmountArg(name, amount, decimals, symbol) { + let amountHex = BigInt(amount).toString(16) + if (amountHex.length % 2) amountHex = `0${amountHex}` + const symbolBytes = Buffer.from(symbol, 'ascii') + const value = Buffer.concat([ + Buffer.from([decimals, symbolBytes.length]), + symbolBytes, + Buffer.from(amountHex, 'hex'), + ]) + return { name, format: TOKEN_AMOUNT, value: value.toString('hex') } +} + +function normalizeAbiValue(value) { + if (typeof value === 'bigint') return value.toString(10) + if (Array.isArray(value)) return value.map(normalizeAbiValue) + return value +} + +function encodeFunction(signature, method, args) { + const iface = new Interface([`function ${signature}`]) + const normalized = args.map(normalizeAbiValue) + // ethers v6 (the declared dev dependency) and the legacy v4 hoisted in the + // monorepo expose the same ABI coder through different method names. + if (typeof iface.encodeFunctionData === 'function') return iface.encodeFunctionData(method, normalized) + return iface.functions[method].encode(normalized) +} + +function uint256Word(value) { + return Buffer.from(BigInt(value).toString(16).padStart(64, '0'), 'hex') +} + +function makeFlow({ key, protocol, category, method, signature, to, abiArgs, displayArgs, + value = '0', chainId = 1, why, sources }) { + const calldata = encodeFunction(signature, method, abiArgs).slice(2) + return { + key, + protocol, + category, + chainId, + to: to.toLowerCase().replace(/^0x/, ''), + value, + selector: calldata.slice(0, 8), + calldata, + method, + signature, + args: displayArgs, + why, + sources, + } +} + +// Morpho Blue wstETH/WETH market 0xc54d…ec41, fetched from the official +// Morpho API. Keeping the full tuple makes this a real, existing market shape. +const MORPHO_MARKET = [ + WETH, + WSTETH, + '0x2a01EB9496094dA03c4E364Def50f5aD1280AD72', + '0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC', + 945000000000000000n, +] + +const usdcTransfer = encodeFunction('transfer(address,uint256)', 'transfer', [RECIPIENT, 1000000n]) +const usdcTransferBytes = Buffer.from(usdcTransfer.slice(2), 'hex') +const packedSafeCall = `0x${Buffer.concat([ + Buffer.from([0]), // CALL, not DELEGATECALL + Buffer.from(USDC.slice(2), 'hex'), + uint256Word(0), + uint256Word(usdcTransferBytes.length), + usdcTransferBytes, +]).toString('hex')}` + +const flows = [ + makeFlow({ + key: 'base-optimism-portal-deposit-eth', + protocol: 'Base Bridge', + category: 'bridges', + method: 'depositTransaction', + signature: 'depositTransaction(address,uint256,uint64,bool,bytes)', + to: '0x49048044D57e1C92A77f79988d21Fa8fAF74E97e', + abiArgs: [RECIPIENT, 100000000000000000n, 100000n, false, '0x'], + value: '100000000000000000', + displayArgs: [ + stringArg('route', 'Ethereum to Base'), + addressArg('recipient', RECIPIENT), + tokenAmountArg('amount', 100000000000000000n, 18, 'ETH'), + stringArg('L2 gas limit', '100000'), + ], + why: 'Deposits payable ETH through the official Base OptimismPortal and mints it to the recipient on Base.', + sources: [ + 'https://docs.base.org/base-chain/specs/protocol/bridging/deposits', + 'https://docs.base.org/base-chain/network-information/base-contracts', + 'https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L1/OptimismPortal2.sol', + ], + }), + makeFlow({ + key: 'lido-withdrawal-queue-request', + protocol: 'Lido', + category: 'staking-withdrawals', + method: 'requestWithdrawals', + signature: 'requestWithdrawals(uint256[],address)', + to: '0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1', + abiArgs: [[1000000000000000000n, 2000000000000000000n], RECIPIENT], + displayArgs: [ + tokenAmountArg('total', 3000000000000000000n, 18, 'stETH'), + stringArg('requests', '2 withdrawals: 1 + 2 stETH'), + addressArg('owner', RECIPIENT), + ], + why: 'Locks stETH in Lido withdrawal requests and mints transferable unstETH claim NFTs to the owner.', + sources: [ + 'https://docs.lido.fi/contracts/withdrawal-queue-erc721', + 'https://github.com/lidofinance/lido-dao/blob/master/contracts/0.8.9/WithdrawalQueue.sol', + ], + }), + makeFlow({ + key: 'lido-withdrawal-queue-claim-batch', + protocol: 'Lido', + category: 'staking-withdrawals', + method: 'claimWithdrawals', + signature: 'claimWithdrawals(uint256[],uint256[])', + to: '0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1', + abiArgs: [[12345n, 12346n], [100n, 100n]], + displayArgs: [ + stringArg('requests', 'unstETH #12345, #12346'), + stringArg('count', 'claim 2 finalized requests'), + stringArg('recipient', 'signing wallet'), + ], + why: 'Burns two finalized unstETH claim NFTs and returns their reserved ETH to the signing wallet.', + sources: ['https://docs.lido.fi/contracts/withdrawal-queue-erc721'], + }), + makeFlow({ + key: 'morpho-blue-borrow-weth', + protocol: 'Morpho Blue', + category: 'lending', + method: 'borrow', + signature: 'borrow(tuple(address,address,address,address,uint256),uint256,uint256,address,address)', + to: '0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb', + abiArgs: [MORPHO_MARKET, 100000000000000000n, 0n, RECIPIENT, RECIPIENT], + displayArgs: [ + stringArg('market', 'Morpho wstETH / WETH 94.5%'), + tokenAmountArg('borrow', 100000000000000000n, 18, 'WETH'), + addressArg('debt owner', RECIPIENT), + addressArg('receiver', RECIPIENT), + ], + why: 'Creates WETH debt against the specified Morpho market and sends borrowed funds to the receiver.', + sources: [ + 'https://docs.morpho.org/developers/contracts/morpho/', + 'https://docs.morpho.org/developers/contracts/addresses/', + 'https://api.morpho.org/v0/blue/markets/1:0xc54d7acf14de29e0e5527cabd7a576506870346a78a11a6762e2cca66322ec41', + 'https://github.com/morpho-org/morpho-blue/blob/main/src/interfaces/IMorpho.sol', + ], + }), + makeFlow({ + key: 'morpho-blue-repay-weth', + protocol: 'Morpho Blue', + category: 'lending', + method: 'repay', + signature: 'repay(tuple(address,address,address,address,uint256),uint256,uint256,address,bytes)', + to: '0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb', + abiArgs: [MORPHO_MARKET, 100000000000000000n, 0n, RECIPIENT, '0x'], + displayArgs: [ + stringArg('market', 'Morpho wstETH / WETH 94.5%'), + tokenAmountArg('repay', 100000000000000000n, 18, 'WETH'), + addressArg('debt owner', RECIPIENT), + stringArg('callback', 'none'), + ], + why: 'Transfers WETH into Morpho to reduce the named account debt; empty callback data prevents an external hook.', + sources: [ + 'https://docs.morpho.org/developers/borrow/concepts/market-mechanics/', + 'https://github.com/morpho-org/morpho-blue/blob/main/src/interfaces/IMorpho.sol', + ], + }), + makeFlow({ + key: 'eigenlayer-delegation-manager-delegate', + protocol: 'EigenLayer', + category: 'restaking', + method: 'delegateTo', + signature: 'delegateTo(address,tuple(bytes,uint256),bytes32)', + to: '0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A', + abiArgs: [SPENDER, ['0x', 1830000000n], `0x${'ab'.repeat(32)}`], + displayArgs: [ + addressArg('operator', SPENDER), + stringArg('approval', 'no approver signature'), + stringArg('expiry', '2027-12-28 13:20 UTC'), + stringArg('effect', 'delegate all EigenLayer stake'), + ], + why: 'Delegates the signing wallet restaked assets to one EigenLayer operator, changing who can operate that stake.', + sources: [ + 'https://github.com/Layr-Labs/eigenlayer-contracts/blob/main/src/contracts/core/DelegationManager.sol', + 'https://github.com/Layr-Labs/eigenlayer-contracts/blob/main/src/contracts/interfaces/IDelegationManager.sol', + ], + }), + makeFlow({ + key: 'safe-multisend-usdc-transfer', + protocol: 'Safe', + category: 'account-abstraction', + method: 'multiSend', + signature: 'multiSend(bytes)', + to: '0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526', + abiArgs: [packedSafeCall], + displayArgs: [ + stringArg('batch', '1 CALL (no delegatecall)'), + addressArg('target', USDC), + tokenAmountArg('transfer', 1000000n, 6, 'USDC'), + addressArg('recipient', RECIPIENT), + ], + why: 'Executes a packed Safe batch containing one USDC transfer; the operation byte is CALL, not DELEGATECALL.', + sources: [ + 'https://github.com/safe-fndn/safe-smart-account/blob/main/contracts/libraries/MultiSend.sol', + 'https://raw.githubusercontent.com/safe-global/safe-deployments/main/src/assets/v1.4.1/multi_send.json', + ], + }), + makeFlow({ + key: 'permit2-lockdown-usdc-dai', + protocol: 'Uniswap Permit2', + category: 'approvals', + method: 'lockdown', + signature: 'lockdown(tuple(address,address)[])', + to: '0x000000000022D473030F116dDEE9F6B43aC78BA3', + abiArgs: [[[USDC, SPENDER], [DAI, SPENDER]]], + displayArgs: [ + stringArg('action', 'revoke 2 Permit2 allowances'), + stringArg('tokens', 'USDC and DAI'), + addressArg('spender', SPENDER), + ], + why: 'Batch-revokes the named spender Permit2 allowances for USDC and DAI.', + sources: [ + 'https://github.com/Uniswap/permit2/blob/main/src/interfaces/IAllowanceTransfer.sol', + 'https://github.com/Uniswap/permit2-sdk/blob/main/abis/Permit2.json', + ], + }), +] + +module.exports = Object.fromEntries(flows.map((flow) => [flow.key, flow])) diff --git a/projects/keepkey-sdk/tests/fixtures/solana-schema.js b/projects/keepkey-sdk/tests/fixtures/solana-schema.js index 7f160858..613d08f7 100644 --- a/projects/keepkey-sdk/tests/fixtures/solana-schema.js +++ b/projects/keepkey-sdk/tests/fixtures/solana-schema.js @@ -35,9 +35,10 @@ const ARG_U64 = 1 const ARG_U8 = 2 const ARG_PUBKEY = 3 const ARG_OPAQUE32 = 4 +const ARG_LAMPORTS = 5 /** Byte width each arg consumes in the instruction data (solana_schemaArgWidth). */ -const ARG_WIDTH = { [ARG_U64]: 8, [ARG_U8]: 1, [ARG_PUBKEY]: 32, [ARG_OPAQUE32]: 32 } +const ARG_WIDTH = { [ARG_U64]: 8, [ARG_U8]: 1, [ARG_PUBKEY]: 32, [ARG_OPAQUE32]: 32, [ARG_LAMPORTS]: 8 } /** * REAL Relay bridge instructions, captured from api.relay.link on 2026-07-27. @@ -227,6 +228,7 @@ function decodeArgs(schema, data) { let value switch (arg.type) { case ARG_U64: + case ARG_LAMPORTS: value = buf.readBigUInt64LE(off) break case ARG_U8: @@ -278,7 +280,7 @@ const CATALOG = { programName: 'Relay Bridge', instructionName: 'depositNative', args: [ - { type: ARG_U64, label: 'Amount' }, + { type: ARG_LAMPORTS, label: 'Amount' }, { type: ARG_OPAQUE32, label: 'Order' }, ], accounts: [{ index: 3, label: 'Vault' }], @@ -318,6 +320,7 @@ module.exports = { ARG_U8, ARG_PUBKEY, ARG_OPAQUE32, + ARG_LAMPORTS, ARG_WIDTH, RELAY_PROGRAM_B58, RELAY_NATIVE_DISC, diff --git a/projects/keepkey-vault/__tests__/advanced-mode-routing.test.ts b/projects/keepkey-vault/__tests__/advanced-mode-routing.test.ts new file mode 100644 index 00000000..28a4c3fa --- /dev/null +++ b/projects/keepkey-vault/__tests__/advanced-mode-routing.test.ts @@ -0,0 +1,102 @@ +/** + * A user must never get a dead reject for a transaction that AdvancedMode would + * allow. They get an opt-in. + * + * SwapDialog decides which of those two happens by testing the raw error text: + * + * if (/AdvancedMode/i.test(raw)) { setBlindSignCause('device'); + * setPhase('blind-signing-required') } + * + * That opens the panel whose Enable button calls applyPolicy. Anything that + * does NOT match falls through to the generic "Cancelled on device" error — + * which is exactly the dead end this replaced (hdwallet flattens the device's + * "Blind signing disabled by policy" to a bare "Action cancelled", matching + * nothing). + * + * So the routing depends on a phrase inside a human-readable sentence. These + * tests pin that contract, because rewording the copy is a normal, innocuous- + * looking edit that would silently turn the prompt back into a reject. + * + * Run: bun test __tests__/advanced-mode-routing.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { readFileSync } from 'fs' +import { evmAdvancedModeRequiredMessage, SOLANA_BLIND_SIGNING_REQUIRED } from '../src/shared/types' + +/** Verbatim from SwapDialog.tsx — keep in sync with the branch it mirrors. */ +const SWAP_DIALOG_ADVANCED_MODE_ROUTE = /AdvancedMode/i + +describe('the EVM blind-sign message routes to the opt-in panel', () => { + test('matches the SwapDialog route', () => { + expect(SWAP_DIALOG_ADVANCED_MODE_ROUTE.test(evmAdvancedModeRequiredMessage('Ethereum'))).toBe(true) + }) + + test('matches for every chain name it can be built with', () => { + for (const coin of ['Ethereum', 'Base', 'Arbitrum', 'Avalanche', 'Polygon', 'BNB Smart Chain']) { + expect(SWAP_DIALOG_ADVANCED_MODE_ROUTE.test(evmAdvancedModeRequiredMessage(coin))).toBe(true) + } + }) + + test('names the chain, so the panel is not generic', () => { + expect(evmAdvancedModeRequiredMessage('Base')).toContain('Base') + }) + + test('does not tell the user to go and do it elsewhere', () => { + // The panel offers a button. Copy that says "enable it on your KeepKey and + // try again" sends the user to device settings for something the dialog is + // about to do for them. + const msg = evmAdvancedModeRequiredMessage('Ethereum').toLowerCase() + expect(msg).not.toContain('try again') + expect(msg).not.toContain('try the swap again') + }) + + test('says the setting does not survive a reboot', () => { + // AdvancedMode is session state (firmware #373). Users who enable it once + // and hit the same wall after a power cycle need to know why. + expect(evmAdvancedModeRequiredMessage('Ethereum').toLowerCase()).toContain('reboot') + }) +}) + +describe('the routing predicate itself', () => { + test('a bare device cancel does NOT match — this is the dead end being fixed', () => { + // What hdwallet produces today: transport.ts constructs a fresh + // core.ActionCancelled() and discards the firmware's + // "Blind signing disabled by policy". Nothing downstream can tell a policy + // refusal from the user pressing Cancel. + expect(SWAP_DIALOG_ADVANCED_MODE_ROUTE.test('Action cancelled')).toBe(false) + }) + + test('a firmware-worded refusal DOES match', () => { + // The route is content-based on purpose: it also catches refusals phrased + // by firmware versions this code has never seen. + expect(SWAP_DIALOG_ADVANCED_MODE_ROUTE.test('Blind signing requires AdvancedMode. Enable in device settings.')).toBe(true) + expect(SWAP_DIALOG_ADVANCED_MODE_ROUTE.test('Enable AdvancedMode to blind-sign')).toBe(true) + expect(SWAP_DIALOG_ADVANCED_MODE_ROUTE.test('AdvancedMode required for clearsign metadata')).toBe(true) + }) + + test('the Solana path keeps its own token and is unaffected', () => { + // Solana routes on an exact sentinel, not on content, because it carries a + // JSON outflow payload appended to the message. + expect(SOLANA_BLIND_SIGNING_REQUIRED).toBe('SOLANA_BLIND_SIGNING_REQUIRED') + }) +}) + +describe('Solana schema fallback preserves the outflow check', () => { + const swapSource = readFileSync(new URL('../src/bun/swap.ts', import.meta.url), 'utf8') + + test('both predicted and device-refused fallbacks use the shared safety helper', () => { + const calls = swapSource.match(/throw await buildSolanaBlindSignRequirement\(\)/g) || [] + expect(calls).toHaveLength(2) + }) + + test('the shared helper runs the outflow simulation before building the sentinel', () => { + const helperStart = swapSource.indexOf('const buildSolanaBlindSignRequirement') + const helperEnd = swapSource.indexOf('\n if (\n needsOpaqueSolanaFallback', helperStart) + const helper = swapSource.slice(helperStart, helperEnd) + expect(helperStart).toBeGreaterThan(-1) + expect(helperEnd).toBeGreaterThan(helperStart) + expect(helper).toContain('checkSolanaOutflow') + expect(helper).toContain('SOLANA_BLIND_SIGNING_REQUIRED') + expect(helper.indexOf('checkSolanaOutflow')).toBeLessThan(helper.indexOf('SOLANA_BLIND_SIGNING_REQUIRED')) + }) +}) diff --git a/projects/keepkey-vault/__tests__/balance-display-state.test.ts b/projects/keepkey-vault/__tests__/balance-display-state.test.ts new file mode 100644 index 00000000..01342e40 --- /dev/null +++ b/projects/keepkey-vault/__tests__/balance-display-state.test.ts @@ -0,0 +1,70 @@ +/** + * The dashboard must never assert a balance it was not given. + * + * The failure this locks: getBalances RESOLVES on a partial portfolio response + * (src/bun/index.ts — "failed chains will show 0"), so `loadingBalances` is + * already false when those rows render. A two-state loading/loaded predicate + * therefore renders "0 ETH" for a chain nobody successfully queried, and + * because a cache exists the Pioneer error banner is deferred by + * PIONEER_ERROR_GRACE_MS (5 minutes) — up to five minutes of unaccompanied, + * confident, wrong zero. + * + * Run: bun test __tests__/balance-display-state.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { balanceDisplayState } from '../src/shared/balance-display-state' + +const base = { loadingBalances: false, initialLoaded: true } + +describe('balanceDisplayState', () => { + // ── pending: an answer is genuinely still in flight ── + test('cold start, nothing loaded yet -> pending', () => { + expect(balanceDisplayState({ hasEntry: false, loadingBalances: false, initialLoaded: false })).toBe('pending') + }) + + test('refresh in flight, no entry yet -> pending', () => { + expect(balanceDisplayState({ hasEntry: false, loadingBalances: true, initialLoaded: true })).toBe('pending') + }) + + test('refresh in flight but we already have a value -> known (stale-while-revalidate)', () => { + // Do not blank out a good number just because a refresh started. + expect(balanceDisplayState({ hasEntry: true, loadingBalances: true, initialLoaded: true })).toBe('known') + }) + + // ── known: we have a real figure, including a real zero ── + test('a verified zero is KNOWN, not unknown — an empty wallet is a fact', () => { + expect(balanceDisplayState({ hasEntry: true, syncState: 'confirmed', ...base })).toBe('known') + }) + + test('no syncState (cached / legacy row) is known, not unknown', () => { + // syncState is optional; absent means "nobody flagged this", not "untrusted". + // Treating undefined as unknown would blank every cached row on startup. + expect(balanceDisplayState({ hasEntry: true, ...base })).toBe('known') + }) + + test("'stale' is a real number that is merely old -> known", () => { + expect(balanceDisplayState({ hasEntry: true, syncState: 'stale', ...base })).toBe('known') + }) + + // ── unknown: the cases the two-state predicate got wrong ── + test('settled with no entry (chunk failed / chain omitted) -> unknown, NOT zero', () => { + expect(balanceDisplayState({ hasEntry: false, ...base })).toBe('unknown') + }) + + test('entry present but backend flagged it degraded -> unknown', () => { + expect(balanceDisplayState({ hasEntry: true, syncState: 'degraded', ...base })).toBe('unknown') + }) + + test('a degraded row reading 0 is still unknown — the zero is not evidence', () => { + // This is the exact shape that rendered "0 ETH" for an unreachable chain. + expect(balanceDisplayState({ hasEntry: true, syncState: 'degraded', ...base })).not.toBe('known') + }) + + // ── liveness: no state spins forever ── + test('once settled, nothing is pending — a degraded chain resolves to unknown, not a permanent spinner', () => { + for (const syncState of ['confirmed', 'stale', 'degraded', undefined] as const) { + expect(balanceDisplayState({ hasEntry: true, syncState, ...base })).not.toBe('pending') + } + expect(balanceDisplayState({ hasEntry: false, ...base })).not.toBe('pending') + }) +}) diff --git a/projects/keepkey-vault/__tests__/clearsign-provider-key.test.ts b/projects/keepkey-vault/__tests__/clearsign-provider-key.test.ts new file mode 100644 index 00000000..1c287644 --- /dev/null +++ b/projects/keepkey-vault/__tests__/clearsign-provider-key.test.ts @@ -0,0 +1,179 @@ +/** + * Provider-key ceremony: derivation must be deterministic, and the fingerprint + * must match what the DEVICE shows. + * + * The fingerprint is the only thing an operator can compare between the screen + * and the file they are about to hand a live service. If it disagrees, they + * cannot tell which key they trusted — which defeats the confirm prompt that + * firmware calls the thing "the whole trust model hangs on". + * + * Firmware (signed_metadata.c, signed_metadata_pubkey_fingerprint): + * sha256_Raw(pubkey, 33, digest); data2hex(digest, 4, out); + * i.e. first 4 bytes of SHA-256 over the 33-byte COMPRESSED pubkey, 8 hex chars. + * + * Run: bun test __tests__/clearsign-provider-key.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { createHash } from 'crypto' +import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + deriveProviderKey, + providerFingerprint, + buildProviderKeyFile, + validateProviderCeremony, + writeProviderKeyFile, + PROVIDER_KEY_PATH, + PROVIDER_KEY_WARNING, +} from '../src/shared/clearsign-provider-key' + +// BIP-39 test vector mnemonic. NEVER a real provider key. +const ABANDON = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +describe('validateProviderCeremony', () => { + test('binds the mnemonic word count to the requested device ceremony', () => { + expect(validateProviderCeremony({ childMnemonic: ABANDON, wordCount: 12, index: 0 })).toBe(ABANDON) + expect(() => validateProviderCeremony({ childMnemonic: ABANDON, wordCount: 24, index: 0 })) + .toThrow('has 12 words; expected 24') + }) + + test('accepts only supported BIP-85 word counts and non-hardened indices', () => { + expect(() => validateProviderCeremony({ childMnemonic: ABANDON, wordCount: 15, index: 0 })) + .toThrow('12, 18, or 24') + expect(() => validateProviderCeremony({ childMnemonic: ABANDON, wordCount: 12, index: -1 })) + .toThrow('integer from 0') + expect(() => validateProviderCeremony({ childMnemonic: ABANDON, wordCount: 12, index: 0x80000000 })) + .toThrow('2147483647') + }) +}) + +describe('deriveProviderKey', () => { + test('is deterministic — the whole point of a repeatable ceremony', () => { + const a = deriveProviderKey(ABANDON) + const b = deriveProviderKey(ABANDON) + expect(a).toEqual(b) + expect(a.publicKeyHex).toBe('03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494') + expect(a.fingerprint).toBe('b690735f') + }) + + test('returns a 33-byte COMPRESSED pubkey — what the device loads', () => { + const { publicKeyHex } = deriveProviderKey(ABANDON) + expect(publicKeyHex).toHaveLength(66) + // Compressed keys start 02 or 03; an uncompressed 04 key would be rejected + // by LoadClearsignSigner, which demands exactly 33 bytes. + expect(['02', '03']).toContain(publicKeyHex.slice(0, 2)) + }) + + test('private key is 32 bytes', () => { + expect(deriveProviderKey(ABANDON).privateKeyHex).toHaveLength(64) + }) + + test('normalises whitespace and case rather than deriving a different key', () => { + expect(deriveProviderKey(` ${ABANDON.toUpperCase()} `.replace(/ /g, ' '))) + .toEqual(deriveProviderKey(ABANDON)) + }) + + test('rejects a mnemonic that fails the BIP-39 checksum', () => { + // A typo must not silently yield a valid-looking key whose fingerprint + // would never match any device, leaving the operator unable to tell a typo + // from a bug. + const typo = ABANDON.replace(/about$/, 'abandon') + expect(() => deriveProviderKey(typo)).toThrow('checksum') + }) + + test('rejects empty input', () => { + expect(() => deriveProviderKey('')).toThrow('required') + }) +}) + +describe('providerFingerprint matches the firmware algorithm', () => { + test('is sha256(compressed pubkey)[0:4] as hex', () => { + const { publicKeyHex, fingerprint } = deriveProviderKey(ABANDON) + const expected = createHash('sha256').update(Buffer.from(publicKeyHex, 'hex')).digest('hex').slice(0, 8) + expect(fingerprint).toBe(expected) + expect(fingerprint).toHaveLength(8) + }) + + test('accepts a 0x prefix and mixed case — operators paste both', () => { + const { publicKeyHex, fingerprint } = deriveProviderKey(ABANDON) + expect(providerFingerprint('0x' + publicKeyHex.toUpperCase())).toBe(fingerprint) + }) + + test('refuses an uncompressed or truncated key instead of hashing it anyway', () => { + expect(() => providerFingerprint('04' + '11'.repeat(64))).toThrow('33 bytes') + expect(() => providerFingerprint('03' + '11'.repeat(10))).toThrow('33 bytes') + }) +}) + +describe('buildProviderKeyFile', () => { + const file = buildProviderKeyFile({ + key: deriveProviderKey(ABANDON), + alias: 'Pioneer', + bip85WordCount: 12, + bip85Index: 0, + deviceFingerprint: 'deadbeef', + createdAt: '2026-08-15T00:00:00.000Z', + }) + + test('records the ceremony so the key can be re-derived and audited', () => { + expect(file.ceremony).toEqual({ + bip85WordCount: 12, + bip85Index: 0, + derivationPath: PROVIDER_KEY_PATH, + deviceFingerprint: 'deadbeef', + createdAt: '2026-08-15T00:00:00.000Z', + }) + }) + + test('carries the fingerprint the operator will compare against the device', () => { + expect(file.fingerprint).toBe('b690735f') + expect(file.publicKeyHex).toBe(deriveProviderKey(ABANDON).publicKeyHex) + }) + + test('states plainly that it holds a live key, and what it can and cannot do', () => { + // The honesty is the feature: this key can MISLABEL a transaction but can + // never conceal one, because a runtime signer is annotation-only. + expect(file.warning).toBe(PROVIDER_KEY_WARNING) + expect(file.warning).toContain('plaintext') + expect(file.warning).toContain('mislabel') + expect(file.warning).toContain('cannot remove the raw review') + }) + + test('is versioned, so a later format change is detectable', () => { + expect(file.format).toBe('keepkey-clearsign-provider-key-v1') + }) +}) + +describe('writeProviderKeyFile', () => { + const file = buildProviderKeyFile({ + key: deriveProviderKey(ABANDON), + alias: 'Pioneer', + bip85WordCount: 12, + bip85Index: 0, + createdAt: '2026-08-15T00:00:00.000Z', + }) + + test('creates the secret owner-only from its first byte', () => { + const dir = mkdtempSync(join(tmpdir(), 'keepkey-provider-key-')) + const filePath = join(dir, 'provider.json') + try { + writeProviderKeyFile(filePath, file) + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual(file) + if (process.platform !== 'win32') expect(statSync(filePath).mode & 0o777).toBe(0o600) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('refuses to overwrite an existing path', () => { + const dir = mkdtempSync(join(tmpdir(), 'keepkey-provider-key-')) + const filePath = join(dir, 'provider.json') + try { + writeProviderKeyFile(filePath, file) + expect(() => writeProviderKeyFile(filePath, file)).toThrow() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/projects/keepkey-vault/__tests__/evm-balance-fetch.test.ts b/projects/keepkey-vault/__tests__/evm-balance-fetch.test.ts new file mode 100644 index 00000000..d2ac6e59 --- /dev/null +++ b/projects/keepkey-vault/__tests__/evm-balance-fetch.test.ts @@ -0,0 +1,149 @@ +/** + * getEvmBalance must never report an RPC failure as a zero balance — that is + * what produced "Build preview failed: Insufficient ETH: need 1.650208, have 0" + * on funded accounts when the public RPC hiccuped. + * + * Run: bun test __tests__/evm-balance-fetch.test.ts + */ +import { describe, test, expect, afterEach } from 'bun:test' +import { getEvmBalance } from '../src/bun/evm-rpc' +import { buildEvmTx, readPioneerBalance } from '../src/bun/txbuilder/evm' +import { CHAINS } from '../src/shared/chains' + +const ORIG_FETCH = globalThis.fetch +const ADDR = '0x1111111111111111111111111111111111111111' +const URL = 'https://rpc.example/' + +function mockFetch(jsonResponse: any) { + globalThis.fetch = (async () => new Response(JSON.stringify(jsonResponse), { + status: 200, headers: { 'Content-Type': 'application/json' }, + })) as any +} + +describe('getEvmBalance', () => { + afterEach(() => { globalThis.fetch = ORIG_FETCH }) + + test('decodes a real balance', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, result: '0x16e6b76ecd820000' }) + expect(await getEvmBalance(URL, ADDR)).toBe(1650208000000000000n) + }) + + test('a genuinely empty account is still 0', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, result: '0x0' }) + expect(await getEvmBalance(URL, ADDR)).toBe(0n) + }) + + test('throws (does NOT return 0) when the RPC omits the result', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, result: null }) + expect(getEvmBalance(URL, ADDR)).rejects.toThrow('returned no result') + }) + + test('throws when the RPC returns an error', async () => { + mockFetch({ jsonrpc: '2.0', id: 1, error: { message: 'rate limit exceeded' } }) + expect(getEvmBalance(URL, ADDR)).rejects.toThrow('rate limit exceeded') + }) +}) + +// The unit above only covers the RPC leaf. These drive the builder's whole +// RPC → Pioneer → throw ladder, which is where the misleading "have 0" came +// from. No rpcUrl is passed, so the Pioneer branch is the one under test. +const ethereum = CHAINS.find(c => c.id === 'ethereum')! +const TO = '0x000000000000000000000000000000000000dEaD' + +const pioneer = (balanceResponse: any) => ({ + GetGasPriceByNetwork: async () => ({ data: '1' }), + GetNonceByNetwork: async () => ({ data: { nonce: 7 } }), + GetBalanceAddressByNetwork: async () => balanceResponse, +}) + +describe('buildEvmTx balance fallback', () => { + test('a malformed Pioneer response is unverifiable, not zero', async () => { + // HTTP 200 with no balance field. `|| '0'` used to turn this into an + // empty wallet and produce "Insufficient funds: balance 0". + await expect(buildEvmTx(pioneer({ data: {} }), ethereum, { + to: TO, amount: '0.01', fromAddress: ADDR, + })).rejects.toThrow('Unable to verify') + }) + + test('an empty-string balance is unverifiable too', async () => { + await expect(buildEvmTx(pioneer({ data: { balance: ' ' } }), ethereum, { + to: TO, amount: '0.01', fromAddress: ADDR, + })).rejects.toThrow('Unable to verify') + }) + + test('a thrown Pioneer call is unverifiable', async () => { + const throwing = { + GetGasPriceByNetwork: async () => ({ data: '1' }), + GetNonceByNetwork: async () => ({ data: { nonce: 7 } }), + GetBalanceAddressByNetwork: async () => { throw new Error('502 bad gateway') }, + } + await expect(buildEvmTx(throwing, ethereum, { + to: TO, amount: '0.01', fromAddress: ADDR, + })).rejects.toThrow('Unable to verify') + }) + + test('a VERIFIED zero balance fails the insufficient-funds check', async () => { + // Distinct from the cases above: Pioneer answered, the account really is + // empty. The old `&& nativeBalance > 0n` guard let this build a send. + await expect(buildEvmTx(pioneer({ data: { balance: '0' } }), ethereum, { + to: TO, amount: '0.01', fromAddress: ADDR, + })).rejects.toThrow('Insufficient funds') + }) + + test('a verified balance that covers the send still builds', async () => { + const tx = await buildEvmTx(pioneer({ data: { balance: '1' } }), ethereum, { + to: TO, amount: '0.01', fromAddress: ADDR, + }) + expect(BigInt(tx.value)).toBe(10_000_000_000_000_000n) + }) +}) + +// readPioneerBalance is the single seam behind all FOUR Pioneer balance reads +// (relay swap, THORChain swap, native send, ERC-20 max). The relay path is the +// reason it exists: there, a `|| '0'` default did not merely say "have 0", it +// satisfied the `nativeBalance !== undefined` check, skipped the "unable to +// verify" throw, and then tripped the `relayValue > nativeBalance * 2n` branch +// — telling the user their quote was built for a different address. +describe('readPioneerBalance', () => { + test('reads nativeBalance', () => { + expect(readPioneerBalance({ data: { nativeBalance: '1.5' } }, 'ctx')).toBe('1.5') + }) + + test('falls back to balance when nativeBalance is absent', () => { + expect(readPioneerBalance({ data: { balance: '2.25' } }, 'ctx')).toBe('2.25') + }) + + test('prefers nativeBalance over balance when both are present', () => { + expect(readPioneerBalance({ data: { nativeBalance: '1', balance: '9' } }, 'ctx')).toBe('1') + }) + + test('a real zero survives — it is a verified balance, not a missing one', () => { + expect(readPioneerBalance({ data: { balance: '0' } }, 'ctx')).toBe('0') + expect(readPioneerBalance({ data: { nativeBalance: 0 } }, 'ctx')).toBe('0') + }) + + test('a blank nativeBalance falls through to balance, as the old || chain did', () => { + // Only the `|| '0'` tail was wrong; this fallthrough was not. Dropping it + // would fail closed on a shape that used to work. + expect(readPioneerBalance({ data: { nativeBalance: '', balance: '3' } }, 'ctx')).toBe('3') + expect(readPioneerBalance({ data: { nativeBalance: null, balance: '3' } }, 'ctx')).toBe('3') + }) + + test('throws on a response with no balance field', () => { + expect(() => readPioneerBalance({ data: {} }, 'ctx')).toThrow('no balance field') + }) + + test('throws on a blank balance', () => { + expect(() => readPioneerBalance({ data: { balance: ' ' } }, 'ctx')).toThrow('no balance field') + }) + + test('throws on null/undefined shapes rather than reading through them', () => { + expect(() => readPioneerBalance({ data: { balance: null } }, 'ctx')).toThrow('no balance field') + expect(() => readPioneerBalance({}, 'ctx')).toThrow('no balance field') + expect(() => readPioneerBalance(undefined, 'ctx')).toThrow('no balance field') + }) + + test('names the context so the log says WHICH lookup failed', () => { + expect(() => readPioneerBalance({ data: {} }, '0xabc')).toThrow('0xabc') + }) +}) diff --git a/projects/keepkey-vault/__tests__/failed-fetch-not-zero.test.ts b/projects/keepkey-vault/__tests__/failed-fetch-not-zero.test.ts new file mode 100644 index 00000000..45a69554 --- /dev/null +++ b/projects/keepkey-vault/__tests__/failed-fetch-not-zero.test.ts @@ -0,0 +1,346 @@ +/** + * "A failed lookup is not a zero" — the non-EVM half of the class. + * + * #411/#414 fixed this for EVM (getEvmBalance, readPioneerBalance). That sweep + * never left the EVM builders. These cover where else it lived: + * + * - txbuilder/cosmos.ts — `?? '0'` on all three MAX balance reads, plus a + * frontend '0' that walked past the guard + * - txbuilder/utxo.ts — Promise.allSettled silently dropping an xpub whose + * ListUnspent failed, after which every statement + * about the total describes a subset + * - btc-accounts.ts — a cached-balance filter that hid the failed account + * from the builder's completeness check entirely + * - balance-display-state — the predicates the send form and swap dialog use + * to decide what they may claim + * + * The screens themselves are NOT covered: there is no React render harness in + * this repo, so the wiring from these predicates into SendForm/SwapDialog is + * verified by reading, not by test. + * + * Run: bun test __tests__/failed-fetch-not-zero.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { buildCosmosTx, readCosmosBalance } from '../src/bun/txbuilder/cosmos' +import { buildUtxoTx, estimateUtxoFee } from '../src/bun/txbuilder/utxo' +import { isBalanceUnverified, selectBalanceEntry } from '../src/shared/balance-display-state' +import { BtcAccountManager } from '../src/bun/btc-accounts' + +describe('readCosmosBalance', () => { + test('reads a real balance', () => { + expect(readCosmosBalance({ data: { balances: [{ balance: '778.25' }] } }, 'RUNE')).toBe('778.25') + }) + + test('a real zero survives — a verified empty account is not a missing one', () => { + expect(readCosmosBalance({ data: { balances: [{ balance: '0' }] } }, 'RUNE')).toBe('0') + expect(readCosmosBalance({ data: { balances: [{ balance: 0 }] } }, 'RUNE')).toBe('0') + }) + + test('throws on an empty balances array rather than reading 0 through it', () => { + // The shape a partial/failed portfolio response actually produces. `?? '0'` + // turned this into `0 - fee` → clamped to 0 → "Amount must be greater than + // zero" on a funded account hitting MAX. + expect(() => readCosmosBalance({ data: { balances: [] } }, 'RUNE')).toThrow('Unable to verify') + }) + + test('throws on a missing balance field', () => { + expect(() => readCosmosBalance({ data: { balances: [{}] } }, 'RUNE')).toThrow('no balance field') + }) + + test('throws on blank and null balances', () => { + expect(() => readCosmosBalance({ data: { balances: [{ balance: ' ' }] } }, 'RUNE')).toThrow('no balance field') + expect(() => readCosmosBalance({ data: { balances: [{ balance: null }] } }, 'RUNE')).toThrow('no balance field') + }) + + test('throws on null/undefined shapes rather than reading through them', () => { + expect(() => readCosmosBalance({}, 'RUNE')).toThrow('Unable to verify') + expect(() => readCosmosBalance(undefined, 'RUNE')).toThrow('Unable to verify') + }) +}) + +// ── The frontend-supplied balance can bypass the guard above ───────────────── + +const THOR = { + id: 'thorchain', coin: 'THORChain', symbol: 'RUNE', + caip: 'cosmos:thorchain-mainnet-v1/slip44:931', + decimals: 8, denom: 'rune', chainId: 'thorchain-1', + defaultPath: [0x8000002c, 0x800003a3, 0x80000000, 0, 0], +} as any +const THOR_FROM = 'thor1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfhgnzx' +const TCY_CAIP = 'cosmos:thorchain-mainnet-v1/denom:tcy' + +const cosmosPioneer = (balances: any[]) => ({ + GetAccountInfo: async () => ({ data: { account: { account_number: '17', sequence: '2' } } }), + GetPortfolioBalances: async () => ({ data: { balances } }), +}) + +const tokenMax = (pioneer: any, tokenBalance?: string) => buildCosmosTx(pioneer, THOR, { + to: THOR_FROM, amount: '0', fromAddress: THOR_FROM, isMax: true, caip: TCY_CAIP, tokenBalance, +} as any) + +describe('buildCosmosTx token MAX with a frontend balance', () => { + test('a frontend "0" does not win over the fetch — that is the degraded value', async () => { + // What the send form holds for a chain whose fetch failed. `??` treated the + // string '0' as a supplied balance, so readCosmosBalance never ran and the + // user got "Amount must be greater than zero" on a funded account. + const err = await tokenMax(cosmosPioneer([]), '0').catch((e: Error) => e) + expect(err.message).toContain('Unable to verify') + expect(err.message).not.toContain('greater than zero') + }) + + test('a frontend "0" falls through to a balance the server can confirm', async () => { + const tx = await tokenMax(cosmosPioneer([{ balance: '50' }]), '0') + expect(tx.tx.msg[0].value.amount[0].amount).toBe('5000000000') + }) + + test('a real frontend balance is still trusted (no extra round trip)', async () => { + const tx = await tokenMax({ + GetAccountInfo: async () => ({ data: { account: { account_number: '17', sequence: '2' } } }), + GetPortfolioBalances: async () => { throw new Error('must not be called') }, + }, '12.5') + expect(tx.tx.msg[0].value.amount[0].amount).toBe('1250000000') + }) +}) + +describe('isBalanceUnverified', () => { + test('a degraded entry is unverified', () => { + expect(isBalanceUnverified({ syncState: 'degraded' })).toBe(true) + expect(isBalanceUnverified({ syncState: 'confirmed' })).toBe(false) + // Stale is old, not unknown — it has its own messaging and stays a figure. + expect(isBalanceUnverified({ syncState: 'stale' })).toBe(false) + }) + + test('an entry with no syncState is a real number', () => { + // Cached and legacy rows carry no syncState. Blanking them would wipe every + // balance on a cold start, which is the opposite of the bug being fixed. + expect(isBalanceUnverified({})).toBe(false) + }) + + test('NO entry is unverified, not zero', () => { + // balanceDisplayState never returns 'known' without an entry, and the send + // form has no load state to tell "still coming" from "never arrived". + // Reachable since #410: the chain list is always visible, so Send opens for + // a chain with no entry and `balance?.balance || '0'` printed a firm 0. + expect(isBalanceUnverified(undefined)).toBe(true) + }) + + test('a directly-confirmed asset survives its chain being degraded', () => { + // mergeTrustedBalanceSnapshot merges RPC-proven tokens through a degraded + // chain, so that token's figure is real even when the aggregate is not. + const spl = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + const degradedWithProof = { syncState: 'degraded' as const, confirmedAssetCaips: [spl] } + expect(isBalanceUnverified(degradedWithProof, spl)).toBe(false) + // Anything else on that chain is still unverified. + expect(isBalanceUnverified(degradedWithProof, 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501')).toBe(true) + expect(isBalanceUnverified(degradedWithProof)).toBe(true) + }) + + test('EVM caips compare case-insensitively, non-EVM byte-for-byte', () => { + const lower = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const mixed = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + expect(isBalanceUnverified({ syncState: 'degraded', confirmedAssetCaips: [lower] }, mixed)).toBe(false) + }) +}) + +// ── The two UI-derived states, as the screens actually compose them ────────── + +const SOL_NATIVE = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' +const SOL_USDC = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + +describe('a verified token is not proof of the native gas balance', () => { + // SendForm and SwapDialog both derive two booleans from one entry. Sharing + // one let a directly-confirmed SPL token clear the flag that also gated the + // low-gas warning — so "deposit SOL to send tokens" came back for a wallet + // whose SOL balance was still an unfetched placeholder zero. + const degradedChainProvenToken = { + chainId: 'solana', syncState: 'degraded' as const, confirmedAssetCaips: [SOL_USDC], + } + + test('the token being sent is verified', () => { + expect(isBalanceUnverified(degradedChainProvenToken, SOL_USDC)).toBe(false) + }) + + test('the native coin that pays the fee is NOT', () => { + expect(isBalanceUnverified(degradedChainProvenToken, SOL_NATIVE)).toBe(true) + }) +}) + +describe('confidence is judged against the entry the amount is read from', () => { + // SwapDialog checked its internal `balances` array but fromBalance fell + // through to the `balance` prop. With any non-empty cache missing this chain + // — which also makes the dialog skip its live fetch — a degraded prop passed + // a check that never looked at it, and its zero drove Available/USD/MAX. + const degradedProp = { chainId: 'solana', syncState: 'degraded' as const } + + test('a degraded prop is caught when the cache has no entry for the chain', () => { + const cacheMissingSolana = [{ chainId: 'bitcoin', syncState: 'confirmed' as const }] + const entry = selectBalanceEntry(cacheMissingSolana, degradedProp, 'solana') + expect(entry).toBe(degradedProp) + expect(isBalanceUnverified(entry, SOL_NATIVE)).toBe(true) + }) + + test('the cached entry still wins when it has one', () => { + const cacheWithSolana = [{ chainId: 'solana', syncState: 'confirmed' as const }] + const entry = selectBalanceEntry(cacheWithSolana, degradedProp, 'solana') + expect(isBalanceUnverified(entry, SOL_NATIVE)).toBe(false) + }) + + test('a prop for a different chain is not borrowed', () => { + const prop = { chainId: 'bitcoin', syncState: 'degraded' as const } + expect(selectBalanceEntry([], prop, 'solana')).toBeUndefined() + }) + + test('no cache and no prop yields nothing to read or judge', () => { + expect(selectBalanceEntry([], undefined, 'solana')).toBeUndefined() + }) +}) + +// ── The upstream filter that hid failures from the builder ─────────────────── + +describe('BtcAccountManager.getSpendableXpubs', () => { + const seed = (xpubs: any[]) => { + const mgr = new BtcAccountManager() + ;(mgr as any).accounts = [{ accountIndex: 0, totalBalanceUsd: 0, xpubs }] + return mgr + } + + test('a cached zero is not proof of an empty account', () => { + // This getter used to filter on `parseFloat(xp.balance) > 0` (as + // getFundedXpubs). A degraded chain's cached balance IS '0', so the + // account was dropped before buildUtxoTx could try it — every remaining + // ListUnspent succeeded, unreachableXpubs stayed 0, and MAX swept a subset + // while believing it had swept the wallet. The completeness guard added in + // the previous commit never saw the account it was meant to catch. + const mgr = seed([ + { xpub: 'xpub-funded', scriptType: 'p2wpkh', path: [0x80000054, 0x80000000, 0x80000000], balance: '0.5', balanceUsd: 1 }, + { xpub: 'xpub-degraded', scriptType: 'p2tr', path: [0x80000056, 0x80000000, 0x80000000], balance: '0', balanceUsd: 0 }, + ]) + expect(mgr.getSpendableXpubs().map(x => x.xpub)).toEqual(['xpub-funded', 'xpub-degraded']) + }) + + test('carries scriptType and accountPath through for each xpub', () => { + const mgr = seed([ + { xpub: 'xpub-a', scriptType: 'p2tr', path: [0x80000056, 0x80000000, 0x80000000], balance: '0', balanceUsd: 0 }, + ]) + expect(mgr.getSpendableXpubs()).toEqual([ + { xpub: 'xpub-a', scriptType: 'p2tr', accountPath: [0x80000056, 0x80000000, 0x80000000] }, + ]) + }) + + test('still skips entries with no xpub string', () => { + const mgr = seed([ + { xpub: '', scriptType: 'p2wpkh', path: [0x80000054, 0x80000000, 0x80000000], balance: '0', balanceUsd: 0 }, + ]) + expect(mgr.getSpendableXpubs()).toEqual([]) + }) +}) + +// ── UTXO multi-xpub partial failure ────────────────────────────────────────── + +const BITCOIN = { + id: 'bitcoin', + coin: 'Bitcoin', + symbol: 'BTC', + networkId: 'bip122:000000000019d6689c085ae165831e93', + decimals: 8, + scriptType: 'p2wpkh', +} as any + +const ACCOUNT_PATH = [0x80000000 + 86, 0x80000000, 0x80000000] +const XPUB = 'xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj' +const TO = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' +const ADDRESS = 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr' + +// Two accounts, 0.005 BTC each. `failOnCall` rejects the Nth ListUnspent, which +// is how a single unreachable xpub reaches the builder: allSettled swallows it +// and the account's coins simply vanish from the set. +function pioneerWithFailures(failOnCall: number[]) { + let call = 0 + return { + ListUnspent: async () => { + call++ + if (failOnCall.includes(call)) throw new Error('ListUnspent 503') + return { + data: [{ + txid: `c00b24b617db136acba4d831e31727319e6917123934f9f8b5253c7f0e89a5b${call}`, + vout: 0, + value: '500000', + confirmations: 12, + address: ADDRESS, + path: "m/86'/0'/0'/0/0", + hex: '00', + }], + } + }, + GetFeeRateByNetwork: async () => ({ data: { slow: 1, average: 2, fast: 5 } }), + GetPubkeyInfo: async () => ({ data: [] }), + } +} + +const TWO_ACCOUNTS = [ + { xpub: XPUB, scriptType: 'p2tr', accountPath: ACCOUNT_PATH }, + { xpub: XPUB, scriptType: 'p2tr', accountPath: ACCOUNT_PATH }, +] + +const build = (pioneer: any, extra: Record) => buildUtxoTx(pioneer, BITCOIN, { + to: TO, xpub: XPUB, allXpubs: TWO_ACCOUNTS, scriptTypeOverride: 'p2tr', accountPath: ACCOUNT_PATH, + ...extra, +} as any) + +describe('buildUtxoTx with an unreachable xpub', () => { + test('MAX refuses to build — a sweep that cannot see every coin is not a sweep', async () => { + // The dangerous one: this used to build a valid tx spending only the + // visible half, and call it "max". A wrong amount, signed, no error. + await expect(build(pioneerWithFailures([1]), { amount: '0', isMax: true })) + .rejects.toThrow('Cannot send max') + }) + + test('does not quote "have X" when X is only the reachable subset', async () => { + // 1 of 2 accounts answered → 0.005 visible, 0.01 actually held. The old + // message was "Insufficient funds: have 0.005, need 0.008" — the same + // confident, wrong figure as "Insufficient ETH ... have 0". + const err = await build(pioneerWithFailures([1]), { amount: '0.008' }).catch((e: Error) => e) + expect(err.message).toContain('Cannot verify') + expect(err.message).not.toContain('Insufficient funds') + }) + + test('a total failure blames the server, not a pending confirmation', async () => { + const err = await build(pioneerWithFailures([1, 2]), { amount: '0.001' }).catch((e: Error) => e) + expect(err.message).toContain('Unable to read') + // The old text invented a reason: "the transaction may still be confirming + // — please wait and try again", for what is a balance server outage. + expect(err.message).not.toContain('still be confirming') + }) +}) + +describe('buildUtxoTx with every xpub reachable (unchanged behaviour)', () => { + test('builds normally when both accounts answer', async () => { + const tx = await build(pioneerWithFailures([]), { amount: '0.008' }) + expect(tx.inputs.length).toBeGreaterThan(0) + }) + + test('a genuine shortfall still says "Insufficient funds" with the real total', async () => { + // Nothing failed, so 0.01 BTC IS the whole balance and quoting it is honest. + const err = await build(pioneerWithFailures([]), { amount: '5' }).catch((e: Error) => e) + expect(err.message).toContain('Insufficient funds') + expect(err.message).toContain('have 0.01') + }) +}) + +describe('estimateUtxoFee', () => { + test('returns null rather than a fee quoted against a partial UTXO set', async () => { + expect(await estimateUtxoFee(pioneerWithFailures([1]), BITCOIN, { + to: TO, amount: '0.001', xpub: XPUB, allXpubs: TWO_ACCOUNTS, + scriptTypeOverride: 'p2tr', accountPath: ACCOUNT_PATH, + } as any)).toBeNull() + }) + + test('still estimates when every account answers', async () => { + const est = await estimateUtxoFee(pioneerWithFailures([]), BITCOIN, { + to: TO, amount: '0.001', xpub: XPUB, allXpubs: TWO_ACCOUNTS, + scriptTypeOverride: 'p2tr', accountPath: ACCOUNT_PATH, + } as any) + expect(est).not.toBeNull() + expect(est!.feeSat).toBeGreaterThan(0) + }) +}) diff --git a/projects/keepkey-vault/__tests__/fixtures/solana/relay-deposit-native-alt.json b/projects/keepkey-vault/__tests__/fixtures/solana/relay-deposit-native-alt.json new file mode 100644 index 00000000..209802c3 --- /dev/null +++ b/projects/keepkey-vault/__tests__/fixtures/solana/relay-deposit-native-alt.json @@ -0,0 +1,24 @@ +{ + "_comment": "Real Relay Bridge depositNative v0 transaction captured from a live api.relay.link quote (2026-05-25, vault-backend.log). References exactly one lookup table. resolvedAccounts were fetched live from Solana mainnet RPC (getMultipleAccounts) on 2026-08-24 and are pinned here so tests never depend on live RPC or live Relay routing again — see docs/RETRO-SOLANA-CERTIFIED-2026-08-24-NIGHT.md.", + "rawTxBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQABAknL0n6jdoRjXT9vjGxiCoMawcf5IBHOm3MGcougVNJieSaJN47NUdgEBusMqjtieVvrELbF3Ja8Lg3wPL/uGr8LKszBn1FCanRmyhqFxJjim1RgJzYy4thHuHrPacWu8gEBBQMAAAIEMA2eDd9f1RwG8KmtHAAAAADWISE/xmJkDHVqCjr1QZgKLfqgLF1bT9AcPMG1UWeANQH5CleX0p1W2E0BwNC64/nFjEaXTuuVyg5P1Sf64f/vAgECAgEO", + "expected": { + "version": "v0", + "staticAccounts": ["5y52MbSDL1WWVSHczGef8CjQwRwbVynbYVqSkkmdwcEM", "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2"], + "altTable": "Hm9fUgcn7qwDaiNTFiGh6pNtVATgnaRcmK6Bbx6EMZfP", + "writableIndices": [2], + "readonlyIndices": [1, 14], + "instructionDiscriminatorHex": "0d9e0ddf5fd51c06", + "instructionDataLen": 48 + }, + "resolvedAccountsBase64": { + "_comment": "Keyed by ALT index. Real values from live mainnet resolution — index 14's account happened to be all-zero bytes on-chain.", + "1": "vj5tKF0u6WM1G23usKHpbIgUNczUULJkXyTMJ5YL7kc=", + "2": "ZpY7N+WB3BSg9XPu7ejlSiV9g9CCxUqyCMv/0dwqcMo=", + "14": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "expectedCanonicalOrderBase64": [ + "ZpY7N+WB3BSg9XPu7ejlSiV9g9CCxUqyCMv/0dwqcMo=", + "vj5tKF0u6WM1G23usKHpbIgUNczUULJkXyTMJ5YL7kc=", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + ] +} diff --git a/projects/keepkey-vault/__tests__/fixtures/solana/relay-deposit-native-no-alt.json b/projects/keepkey-vault/__tests__/fixtures/solana/relay-deposit-native-no-alt.json new file mode 100644 index 00000000..7e37a03c --- /dev/null +++ b/projects/keepkey-vault/__tests__/fixtures/solana/relay-deposit-native-no-alt.json @@ -0,0 +1,22 @@ +{ + "_comment": "Real Relay Bridge depositNative v0 transaction returned by the public Pioneer quote API for the operator's repeatedly tested SOL-to-ETH route on 2026-08-24 America/Chicago (2026-08-25 UTC). This is the production shape that exposed the schema-only certification gap: all five instruction accounts are static and the message has zero address lookup entries. The blockhash is intentionally stale; tests inspect unsigned message structure and never broadcast it.", + "rawTxBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADBew5eaTca0Ab0EUXGhifJoVvq56rdVYCFPlysu3BZDAPZpY7N+WB3BSg9XPu7ejlSiV9g9CCxUqyCMv/0dwqcMp5Jok3js1R2AQG6wyqO2J5W+sQtsXclrwuDfA8v+4av74+bShdLuljNRtt7rCh6WyIFDXM1FCyZF8kzCeWC+5HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADZbbn2IvhA/9qXQwII3bx5UNLB6kXsycKTMVHAKWPzhgECBQMAAAEEMA2eDd9f1RwG8HVjOwAAAAADcE3qKl65z5ji9iWpYIDfHwxcJMzsOm2IJ7OrJcCxGAA=", + "expected": { + "version": "v0", + "wireBytes": 320, + "staticAccounts": [ + "Gu83nVMD8qh948D1vqe8UPoUHaFuSwcHrvNHetcM4Xux", + "7uTT8Xi5RWXzy7h9XL244GRgEycDYDhLjr3ZyNdXi8pZ", + "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2", + "Dodg2HifwU8rmaVVyMyUZDGTRbqAJTyVYxXPwcbNpBKc", + "11111111111111111111111111111111" + ], + "altEntries": 0, + "programId": "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2", + "instructionAccountIndices": [3, 0, 0, 1, 4], + "instructionDiscriminatorHex": "0d9e0ddf5fd51c06", + "instructionDataLen": 48, + "amountLamports": "996374000", + "vault": "7uTT8Xi5RWXzy7h9XL244GRgEycDYDhLjr3ZyNdXi8pZ" + } +} diff --git a/projects/keepkey-vault/__tests__/pairing-pubkeys.test.ts b/projects/keepkey-vault/__tests__/pairing-pubkeys.test.ts new file mode 100644 index 00000000..a2114913 --- /dev/null +++ b/projects/keepkey-vault/__tests__/pairing-pubkeys.test.ts @@ -0,0 +1,89 @@ +/** + * Mobile-pairing payload: the paired phone must see every account the desktop + * knows about, not just account 0 (keepkey/keepkey-vault#406). + */ +import { describe, test, expect } from 'bun:test' +import { btcPairingEntries, utxoPairingEntries, evmPairingEntries } from '../src/bun/pairing-pubkeys' + +const CONTEXT = 'keepkey:test.json' +const BTC_NET = 'bip122:000000000019d6689c085ae165831e93' + +const btcMeta = (accountIndex: number, scriptType: any, purpose: number, xpub: string) => ({ + xpub, scriptType, accountIndex, path: [purpose + 0x80000000, 0x80000000, accountIndex + 0x80000000], +}) + +describe('btcPairingEntries', () => { + test('emits one entry per account × script type, with the account in the path', () => { + const entries = btcPairingEntries([ + btcMeta(0, 'p2wpkh', 84, 'zpub-acct0'), + btcMeta(1, 'p2wpkh', 84, 'zpub-acct1'), + ], BTC_NET, CONTEXT) + + expect(entries.map(e => e.path)).toEqual(["m/84'/0'/0'", "m/84'/0'/1'"]) + expect(entries.map(e => e.pathMaster)).toEqual(["m/84'/0'/0'/0/0", "m/84'/0'/1'/0/0"]) + expect(entries[1].addressNList).toEqual([0x80000054, 0x80000000, 0x80000001]) + expect(entries[1].note).toContain('account 1') + // xpub-prefixed `type` and the SDK's `address` alias are part of the payload contract + expect(entries[0].type).toBe('zpub') + expect(entries[0].address).toBe('zpub-acct0') + expect(entries[0].networks).toEqual([BTC_NET]) + }) + + test('available_scripts_types covers every derived script type', () => { + const entries = btcPairingEntries([ + btcMeta(0, 'p2pkh', 44, 'xpub0'), + btcMeta(0, 'p2wpkh', 84, 'zpub0'), + btcMeta(1, 'p2wpkh', 84, 'zpub1'), + ], BTC_NET, CONTEXT) + expect(entries[0].available_scripts_types).toEqual(['p2pkh', 'p2wpkh', 'p2sh']) + }) + + test('drops empty xpubs', () => { + expect(btcPairingEntries([btcMeta(0, 'p2wpkh', 84, '')], BTC_NET, CONTEXT)).toEqual([]) + }) +}) + +describe('utxoPairingEntries', () => { + const chains = [{ id: 'litecoin', symbol: 'LTC', networkId: 'bip122:ltc', scriptType: 'p2wpkh' }] + + test('keeps tracked accounts > 0 and dedups the account-0 xpub', () => { + const entries = utxoPairingEntries([ + { chainId: 'litecoin', xpub: 'zpub-ltc0', scriptType: 'p2wpkh', path: [0x80000054, 0x80000002, 0x80000000] }, + { chainId: 'litecoin', xpub: 'zpub-ltc0', scriptType: 'p2wpkh', path: [0x80000054, 0x80000002, 0x80000000] }, + { chainId: 'litecoin', xpub: 'zpub-ltc1', scriptType: 'p2wpkh', path: [0x80000054, 0x80000002, 0x80000001] }, + ], chains, CONTEXT) + + expect(entries.map(e => e.pubkey)).toEqual(['zpub-ltc0', 'zpub-ltc1']) + expect(entries[1].pathMaster).toBe("m/84'/2'/1'/0/0") + expect(entries[1].note).toContain('account 1') + }) + + test('ignores rows for chains not in the pairing set', () => { + const entries = utxoPairingEntries( + [{ chainId: 'dogecoin', xpub: 'dgub', path: [0x8000002C, 0x80000003, 0x80000000] }], + chains, CONTEXT, + ) + expect(entries).toEqual([]) + }) +}) + +describe('evmPairingEntries', () => { + test('emits one entry per tracked index at the account-hardened path', () => { + const entries = evmPairingEntries( + [{ address: '0xaaa', addressIndex: 0 }, { address: '0xbbb', addressIndex: 2 }], + ['eip155:1', 'eip155:*'], CONTEXT, + ) + expect(entries.map(e => e.pathMaster)).toEqual(["m/44'/60'/0'/0/0", "m/44'/60'/2'/0/0"]) + expect(entries[1].addressNList).toEqual([0x8000002C, 0x8000003C, 0x80000002]) + expect(entries[1].networks).toEqual(['eip155:1', 'eip155:*']) + expect(entries[0].note).toBe('ETH primary (default)') + }) + + test('dedups the same address case-insensitively', () => { + const entries = evmPairingEntries( + [{ address: '0xAbC', addressIndex: 0 }, { address: '0xabc', addressIndex: 0 }], + ['eip155:1'], CONTEXT, + ) + expect(entries).toHaveLength(1) + }) +}) diff --git a/projects/keepkey-vault/__tests__/robinhood-chain.test.ts b/projects/keepkey-vault/__tests__/robinhood-chain.test.ts new file mode 100644 index 00000000..17314dec --- /dev/null +++ b/projects/keepkey-vault/__tests__/robinhood-chain.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import assetData from '../src/shared/assetData.json' +import { CHAINS } from '../src/shared/chains' + +const ROBINHOOD_CAIP = 'eip155:4663/slip44:60' + +describe('Robinhood Chain registry', () => { + test('uses official mainnet identifiers and native ETH consistently', () => { + const chain = CHAINS.find((candidate) => candidate.id === 'robinhood') + expect(chain).toBeDefined() + expect(chain?.networkId).toBe('eip155:4663') + expect(chain?.caip).toBe(ROBINHOOD_CAIP) + expect(chain?.chainId).toBe('4663') + expect(chain?.symbol).toBe('ETH') + expect((assetData as Record)[ROBINHOOD_CAIP]?.symbol).toBe('ETH') + }) + + test('uses the standard Ethereum derivation path and official explorer', () => { + const chain = CHAINS.find((candidate) => candidate.id === 'robinhood')! + expect(chain.defaultPath).toEqual([0x8000002C, 0x8000003C, 0x80000000, 0, 0]) + expect(chain.explorerTxUrl).toBe('https://robinhoodchain.blockscout.com/tx/{{txid}}') + expect(chain.explorerAddressUrl).toBe('https://robinhoodchain.blockscout.com/address/{{address}}') + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-certified-fixture.test.ts b/projects/keepkey-vault/__tests__/solana-certified-fixture.test.ts new file mode 100644 index 00000000..a867b788 --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-certified-fixture.test.ts @@ -0,0 +1,121 @@ +/** + * Pinned regression fixture: a real Relay Bridge depositNative v0 transaction + * that genuinely references an Address Lookup Table. Captured 2026-05-25; + * resolved accounts captured live from mainnet RPC 2026-08-24. + * + * This exists so the certified LUT path can be tested end-to-end without + * depending on live RPC or on Relay's routing happening to return an + * ALT-backed route on any given day — see + * docs/RETRO-SOLANA-CERTIFIED-2026-08-24-NIGHT.md for why that dependency + * burned a full night. + */ +import { describe, test, expect } from 'bun:test' +import bs58 from 'bs58' +import { parseSolanaTx, solanaMessageSlice, parseSolanaMessage } from '../src/bun/solana-tx' +import { resolveCanonicalLutAccounts } from '../src/bun/solana-lut-resolver' +import { CERTIFIED_SOLANA_CATALOG } from '../src/bun/solana-certified-schema' +import type { AltAccountFetcher } from '../src/bun/solana-alt' + +import fixture from './fixtures/solana/relay-deposit-native-alt.json' +import noAltFixture from './fixtures/solana/relay-deposit-native-no-alt.json' + +function fetcherFromFixture(): AltAccountFetcher { + const resolved = fixture.resolvedAccountsBase64 as Record + const maxIndex = Math.max(...Object.keys(resolved).filter((k) => k !== '_comment').map(Number)) + const addresses = Buffer.alloc(0) + const table = Buffer.concat([ + Buffer.alloc(56), // header, discriminator=0 is fine — resolver only reads addresses past offset 56 + ...Array.from({ length: maxIndex + 1 }, (_, i) => + resolved[String(i)] ? Buffer.from(resolved[String(i)], 'base64') : Buffer.alloc(32)), + ]) + table.writeUInt32LE(1, 0) // LookupTable discriminator + return async (keys: string[]) => keys.map((k) => + k === fixture.expected.altTable ? { data: table, owner: 'AddressLookupTab1e1111111111111111111111111' } : null) +} + +describe('pinned fixture: real Relay depositNative v0 tx with an ALT reference', () => { + test('parses to the expected structure', () => { + const fullTx = Buffer.from(fixture.rawTxBase64, 'base64') + const parsed = parseSolanaTx(fullTx) + const messageBytes = solanaMessageSlice(fullTx, parsed) + const message = parseSolanaMessage(messageBytes) + + expect(message.version).toBe(fixture.expected.version) + expect(message.staticAccounts.map((a) => bs58.encode(a))).toEqual(fixture.expected.staticAccounts) + expect(message.altEntries.length).toBe(1) + expect(bs58.encode(message.altEntries[0].accountKey)).toBe(fixture.expected.altTable) + expect(message.altEntries[0].writableIndices).toEqual(fixture.expected.writableIndices) + expect(message.altEntries[0].readonlyIndices).toEqual(fixture.expected.readonlyIndices) + + const ix = message.instructions[0] + expect(Buffer.from(ix.data).subarray(0, 8).toString('hex')).toBe(fixture.expected.instructionDiscriminatorHex) + expect(ix.data.length).toBe(fixture.expected.instructionDataLen) + }) + + test('matches the reviewed relayDepositNative catalog entry', () => { + const fullTx = Buffer.from(fixture.rawTxBase64, 'base64') + const messageBytes = solanaMessageSlice(fullTx, parseSolanaTx(fullTx)) + const message = parseSolanaMessage(messageBytes) + const ix = message.instructions[0] + const programKey = bs58.encode(message.staticAccounts[ix.programIdIndex]) + const spec = CERTIFIED_SOLANA_CATALOG.relayDepositNative + expect(programKey).toBe(spec.programId) + expect(Buffer.from(ix.data).subarray(0, spec.discriminator.length)).toEqual(spec.discriminator) + }) + + test('resolveCanonicalLutAccounts reproduces the real, live-RPC-verified account order', async () => { + const fullTx = Buffer.from(fixture.rawTxBase64, 'base64') + const messageBytes = solanaMessageSlice(fullTx, parseSolanaTx(fullTx)) + const message = parseSolanaMessage(messageBytes) + + const result = await resolveCanonicalLutAccounts(message, fetcherFromFixture()) + const accountsBase64 = result.accounts.map((a) => a.toString('base64')) + expect(accountsBase64).toEqual(fixture.expectedCanonicalOrderBase64) + expect(result.writableCount).toBe(1) + expect(result.readonlyCount).toBe(2) + }) +}) + +describe('pinned fixture: real Relay depositNative v0 tx with static accounts only', () => { + test('parses to the exact live no-LUT structure', () => { + const fullTx = Buffer.from(noAltFixture.rawTxBase64, 'base64') + const parsed = parseSolanaTx(fullTx) + const messageBytes = solanaMessageSlice(fullTx, parsed) + const message = parseSolanaMessage(messageBytes) + + expect(fullTx.length).toBe(noAltFixture.expected.wireBytes) + expect(message.version).toBe(noAltFixture.expected.version) + expect(message.staticAccounts.map((a) => bs58.encode(a))).toEqual(noAltFixture.expected.staticAccounts) + expect(message.altEntries).toEqual([]) + expect(message.altEntries.length).toBe(noAltFixture.expected.altEntries) + + expect(message.instructions).toHaveLength(1) + const ix = message.instructions[0] + expect(bs58.encode(message.staticAccounts[ix.programIdIndex])).toBe(noAltFixture.expected.programId) + expect(ix.accountIndices).toEqual(noAltFixture.expected.instructionAccountIndices) + expect(Buffer.from(ix.data).subarray(0, 8).toString('hex')).toBe(noAltFixture.expected.instructionDiscriminatorHex) + expect(ix.data.length).toBe(noAltFixture.expected.instructionDataLen) + expect(Buffer.from(ix.data).readBigUInt64LE(8).toString()).toBe(noAltFixture.expected.amountLamports) + expect(bs58.encode(message.staticAccounts[ix.accountIndices[3]])).toBe(noAltFixture.expected.vault) + }) + + test('matches the same reviewed schema as the ALT-backed route', () => { + const fullTx = Buffer.from(noAltFixture.rawTxBase64, 'base64') + const message = parseSolanaMessage(solanaMessageSlice(fullTx, parseSolanaTx(fullTx))) + const ix = message.instructions[0] + const spec = CERTIFIED_SOLANA_CATALOG.relayDepositNative + + expect(bs58.encode(message.staticAccounts[ix.programIdIndex])).toBe(spec.programId) + expect(Buffer.from(ix.data).subarray(0, spec.discriminator.length)).toEqual(spec.discriminator) + }) + + test('does not manufacture a LUT requirement for a self-contained message', async () => { + const fullTx = Buffer.from(noAltFixture.rawTxBase64, 'base64') + const message = parseSolanaMessage(solanaMessageSlice(fullTx, parseSolanaTx(fullTx))) + + expect(message.altEntries).toHaveLength(0) + await expect(resolveCanonicalLutAccounts(message, async () => { + throw new Error('no RPC lookup should occur for a self-contained message') + })).rejects.toThrow(/no address table lookups/i) + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-hdwallet-contract.test.ts b/projects/keepkey-vault/__tests__/solana-hdwallet-contract.test.ts new file mode 100644 index 00000000..6117b441 --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-hdwallet-contract.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' + +describe('pinned hdwallet certified Solana contract', () => { + test('keeps schema and certificate mandatory while LUT evidence remains optional', async () => { + // This source-contract test guards the parent gitlink itself. A Vault-only + // policy test cannot detect an older hdwallet pin silently dropping the + // certified envelope before the protobuf message reaches firmware. + const [coreSolana, keepkeySolana] = await Promise.all([ + Bun.file(new URL('../../../modules/hdwallet/packages/hdwallet-core/src/solana.ts', import.meta.url)).text(), + Bun.file(new URL('../../../modules/hdwallet/packages/hdwallet-keepkey/src/solana.ts', import.meta.url)).text(), + ]) + + expect(coreSolana).toMatch(/lutProof\?:/) + expect(coreSolana).toMatch(/schema\?:/) + expect(coreSolana).toMatch(/certificate\?:/) + expect(keepkeySolana).toContain('if (msg.lutProof)') + expect(keepkeySolana).toContain('encodeLengthDelimited(5, accountBytes)') + expect(keepkeySolana).toContain('encodeLengthDelimited(6, lutSignature)') + expect(keepkeySolana).toContain('encodeVarintField(7, msg.lutProof.signerKeyId)') + expect(keepkeySolana).toContain('encodeLengthDelimited(13, certificate)') + expect(keepkeySolana).not.toContain('msg.swapMetadata') + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-signing.test.ts b/projects/keepkey-vault/__tests__/solana-signing.test.ts index b98d60b7..23bcd89a 100644 --- a/projects/keepkey-vault/__tests__/solana-signing.test.ts +++ b/projects/keepkey-vault/__tests__/solana-signing.test.ts @@ -39,30 +39,40 @@ function wireTransaction( } describe('Solana transaction signing route', () => { - test('REST schema preserves a complete descriptor but strips caller-asserted blind consent', () => { - const swapMetadata = { - payload: Buffer.from('KKSOLSW1-test').toString('base64'), + test('REST schema preserves a complete LUT proof but strips caller-asserted blind consent', () => { + const lutProof = { + accounts: [Buffer.alloc(32, 2).toString('base64')], signature: Buffer.alloc(64, 1).toString('base64'), signerKeyId: 3, } const parsed = SolanaSignRequest.parse({ raw_tx: wireTransaction().rawTx, addressNList: ADDRESS_N, - swapMetadata, + lutProof, allowBlindSigning: true, }) - expect(parsed.swapMetadata).toEqual(swapMetadata) + expect(parsed.lutProof).toEqual(lutProof) expect('allowBlindSigning' in parsed).toBe(false) }) - test('REST schema rejects partial or out-of-range descriptors', () => { + test('REST schema rejects partial, out-of-range, and mixed-certified proofs', () => { + expect(() => SolanaSignRequest.parse({ + raw_tx: wireTransaction().rawTx, + lutProof: { accounts: [Buffer.alloc(32).toString('base64')], signerKeyId: 3 }, + })).toThrow() + expect(() => SolanaSignRequest.parse({ + raw_tx: wireTransaction().rawTx, + lutProof: { accounts: ['AA=='], signature: 'AA==', signerKeyId: 4 }, + })).toThrow() expect(() => SolanaSignRequest.parse({ raw_tx: wireTransaction().rawTx, - swapMetadata: { payload: 'S0tTT0xTVzE=', signerKeyId: 3 }, + schema: { payload: 'AA==', signature: 'AA==', signerKeyId: 0x80 }, })).toThrow() expect(() => SolanaSignRequest.parse({ raw_tx: wireTransaction().rawTx, - swapMetadata: { payload: 'S0tTT0xTVzE=', signature: 'AA==', signerKeyId: 4 }, + certificate: 'AA==', + schema: { payload: 'AA==', signature: 'AA==', signerKeyId: 0x80 }, + lutProof: { accounts: ['AA=='], signature: 'AA==', signerKeyId: 3 }, })).toThrow() }) diff --git a/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts b/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts index 83acc85c..703a4874 100644 --- a/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts +++ b/projects/keepkey-vault/__tests__/swap-support-matrix.test.ts @@ -26,6 +26,7 @@ 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' +const ROBINHOOD = 'eip155:4663/slip44:60' describe('assessAvailability — natives', () => { test('BTC native is swappable on THORChain + Mayachain + ChainFlip', () => { @@ -86,6 +87,13 @@ describe('assessAvailability — natives', () => { expect(a.providers).toContain('shapeshift') }) + test('Robinhood Chain native ETH → swappable via Relay + ShapeShift', () => { + const a = assessAvailability(ROBINHOOD) + 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 diff --git a/projects/keepkey-vault/__tests__/taproot-host.test.ts b/projects/keepkey-vault/__tests__/taproot-host.test.ts index 9ad49256..df8c6e3a 100644 --- a/projects/keepkey-vault/__tests__/taproot-host.test.ts +++ b/projects/keepkey-vault/__tests__/taproot-host.test.ts @@ -52,6 +52,53 @@ describe('firmware capability gate', () => { ]) expect(set.selectedXpub).toEqual({ accountIndex: 0, scriptType: 'p2wpkh' }) }) + + test('an incompatible optional P2TR adapter cannot erase the required account types', async () => { + const manager = new BtcAccountManager() + await manager.initialize({ + // Reproduces the regressed adapter contract: it claimed support before + // its GetPublicKey wire translator understood p2tr. + btcSupportsScriptType: async (_coin: string, scriptType: string) => scriptType === 'p2tr', + getPublicKeys: async (paths: any[]) => { + if (paths.some(p => p.scriptType === 'p2tr')) throw new Error('unhandled InputSriptType enum: p2tr') + return paths.map((p, i) => ({ xpub: `xpub-${p.scriptType}-${i}` })) + }, + }) + + expect(manager.toAccountSet().accounts[0].xpubs.map(x => x.scriptType)) + .toEqual(['p2pkh', 'p2sh-p2wpkh', 'p2wpkh']) + }) + + test('missing required xpubs fail with an actionable error', async () => { + const manager = new BtcAccountManager() + await expect(manager.initialize({ + btcSupportsScriptType: async () => false, + getPublicKeys: async (paths: any[]) => paths.map((p, i) => i === 1 ? null : { xpub: `xpub-${p.scriptType}-${i}` }), + })).rejects.toThrow('missing required script types: p2sh-p2wpkh') + expect(manager.toAccountSet().accounts).toEqual([]) + }) +}) + +describe('pinned hdwallet Taproot wire contract', () => { + test('the parent repository pin can encode P2TR public-key and change requests', async () => { + // Read the checked-out gitlink sources instead of importing generated jspb. + // CI intentionally does not npm-install the device-protocol submodule, and + // a source-contract test should not gain a hidden google-protobuf runtime + // dependency. These assertions still fail on the regressed hdwallet pin: + // it lacks the core enum values and both KeepKey wire mappings. + const [coreBitcoin, keepkeyUtils, protocolTypes] = await Promise.all([ + Bun.file(new URL('../../../modules/hdwallet/packages/hdwallet-core/src/bitcoin.ts', import.meta.url)).text(), + Bun.file(new URL('../../../modules/hdwallet/packages/hdwallet-keepkey/src/utils.ts', import.meta.url)).text(), + Bun.file(new URL('../../../modules/device-protocol/types.proto', import.meta.url)).text(), + ]) + + expect(coreBitcoin).toMatch(/SpendTaproot\s*=\s*["']p2tr["']/) + expect(coreBitcoin).toMatch(/PayToTaproot\s*=\s*["']p2tr["']/) + expect(keepkeyUtils).toMatch(/case core\.BTCInputScriptType\.SpendTaproot:\s*return Types\.InputScriptType\.SPENDTAPROOT;/) + expect(keepkeyUtils).toMatch(/case core\.BTCOutputScriptType\.PayToTaproot:\s*return Types\.OutputScriptType\.PAYTOTAPROOT;/) + expect(protocolTypes).toMatch(/SPENDTAPROOT\s*=\s*5/) + expect(protocolTypes).toMatch(/PAYTOTAPROOT\s*=\s*6/) + }) }) describe('Taproot discovery consumers', () => { diff --git a/projects/keepkey-vault/clearsign-worker/README.md b/projects/keepkey-vault/clearsign-worker/README.md new file mode 100644 index 00000000..fdfeff42 --- /dev/null +++ b/projects/keepkey-vault/clearsign-worker/README.md @@ -0,0 +1,45 @@ +# KeepKey ClearSign Worker + +Production-facing 7.16 certified-description service for reviewed Relay and +Portals actions on Ethereum and Solana. It publishes status and provenance, +refuses unknown transaction shapes, and signs only with Cloudflare encrypted +secrets. + +The delegate private key never belongs in this repository, `wrangler.toml`, a +command argument, or a Worker variable. The two public certificates are also +stored as secrets so provisioning is atomic and consistent across deployments. + +## What leaves Vault + +- Ethereum sends `chainId`, contract, selector, and calldata length. It does + not send transaction arguments or calldata. KeepKey decodes the real values; + the service cannot supply them. +- Solana sends the unsigned transaction and reviewed catalog id. The service + parses the transaction, resolves its lookup-table accounts from Solana RPC, + and signs a binding to the exact message. Seeds, private keys, PINs, + passphrases, and device signatures never leave KeepKey. + +The Worker writes no transaction database. Cloudflare's platform-level +request and security telemetry remains governed by the account configuration. + +## Verification and deployment + +```sh +bun test clearsign-worker/src/index.test.ts +npx wrangler deploy --config clearsign-worker/wrangler.toml +``` + +Provision these encrypted secrets through stdin: + +```sh +npx wrangler secret put CLEARSIGN_DELEGATE_PRIVATE_KEY --config clearsign-worker/wrangler.toml +npx wrangler secret put CLEARSIGN_CERTIFICATE_HEX --config clearsign-worker/wrangler.toml +npx wrangler secret put CLEARSIGN_SOLANA_CERTIFICATE_HEX --config clearsign-worker/wrangler.toml +``` + +`/ready` returns 200 when the delegate key matches fingerprint `a9531b9d` and +at least one root-signed, scope-correct certificate is active. `/v1/status` +reports Ethereum and Solana readiness separately; an unprovisioned scope always +fails closed. Before pointing Vault at a new deployment, test exact positive +routes and tampered chain, contract, selector, instruction length, program, +lookup table, certificate, and signature cases. diff --git a/projects/keepkey-vault/clearsign-worker/src/index.test.ts b/projects/keepkey-vault/clearsign-worker/src/index.test.ts new file mode 100644 index 00000000..6526f967 --- /dev/null +++ b/projects/keepkey-vault/clearsign-worker/src/index.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'bun:test' +import bs58 from 'bs58' + +import worker from './index' + +const fetchWorker = (path: string, init?: RequestInit, env: Record = {}) => + worker.fetch(new Request(`https://clearsign.example${path}`, init), env) + +function relayLegacyTx(options: { extraDataByte?: boolean; wrongProgram?: boolean } = {}): string { + const program = options.wrongProgram + ? Buffer.alloc(32, 0x77) + : Buffer.from(bs58.decode('99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2')) + const data = Buffer.concat([ + Buffer.from('0d9e0ddf5fd51c06', 'hex'), + Buffer.alloc(8, 0x01), + Buffer.alloc(32, 0x02), + ...(options.extraDataByte ? [Buffer.from([0xff])] : []), + ]) + const message = Buffer.concat([ + Buffer.from([1, 0, 1]), + Buffer.from([5]), + Buffer.alloc(32, 0x10), + Buffer.alloc(32, 0x11), + Buffer.alloc(32, 0x12), + Buffer.alloc(32, 0x13), + program, + Buffer.alloc(32, 0x20), + Buffer.from([1, 4, 4, 0, 1, 2, 3, data.length]), + data, + ]) + return Buffer.concat([Buffer.from([1]), Buffer.alloc(64), message]).toString('base64') +} + +const post = (path: string, body: unknown, env: Record = {}) => fetchWorker(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), +}, env) + +describe('ClearSign Worker public surface', () => { + it('reports online but fail-closed until at least one scope is provisioned', async () => { + const health = await (await fetchWorker('/health')).json() as any + expect(health.ok).toBe(true) + expect(health.ready).toBe(false) + + const ready = await fetchWorker('/ready') + expect(ready.status).toBe(503) + const status = await (await fetchWorker('/v1/status')).json() as any + expect(status.status).toBe('provisioning') + expect(status.scopes).toEqual({ ethereum: 'provisioning', solana: 'provisioning' }) + expect(status.privacy.note).toContain('unsigned transaction') + }) + + it('publishes human-readable provenance for Ethereum and Solana flows', async () => { + const response = await fetchWorker('/v1/catalog') + const body = await response.json() as any + expect(response.status).toBe(200) + expect(body.entries).toHaveLength(4) + expect(body.entries.map((entry: any) => entry.family)).toEqual(['evm', 'evm', 'solana', 'solana']) + for (const entry of body.entries) { + expect(['Relay', 'Portals']).toContain(entry.protocol) + expect(entry.provenance.protocol).toMatch(/^https:\/\//) + } + }) + + it('rejects unknown EVM shapes before checking signer readiness', async () => { + const response = await post('/v1/evm/schema', { + chainId: 1, + contract: '0x0000000000000000000000000000000000000001', + selector: '0x49290c1c', + calldataLength: 68, + }) + expect(response.status).toBe(422) + }) + + it('returns unavailable for an exact reviewed EVM shape without signing', async () => { + const response = await post('/v1/evm/schema', { + chainId: 1, + contract: '0x4cd00e387622c35bddb9b4c962c136462338bc31', + selector: '0x49290c1c', + calldataLength: 68, + }) + expect(response.status).toBe(503) + expect((await response.json() as any).classification).toBe('UNAVAILABLE') + }) + + it('recognizes the dynamic Portals shape but rejects non-word-aligned calldata', async () => { + const exact = await post('/v1/evm/schema', { + chainId: 1, + contract: '0xbf5A7F3629fB325E2a8453D595AB103465F75E62', + selector: '0xa2e42c65', + calldataLength: 1476, + }) + expect(exact.status).toBe(503) + const malformed = await post('/v1/evm/schema', { + chainId: 1, + contract: '0xbf5A7F3629fB325E2a8453D595AB103465F75E62', + selector: '0xa2e42c65', + calldataLength: 1477, + }) + expect(malformed.status).toBe(422) + }) + + it('parses and exact-matches a reviewed Solana instruction before provisioning', async () => { + const response = await post('/v1/solana/certify', { + rawTx: relayLegacyTx(), + catalogKey: 'relayDepositNative', + }) + expect(response.status).toBe(503) + expect((await response.json() as any).classification).toBe('UNAVAILABLE') + }) + + it('refuses malformed, wrong-program, wrong-length, and unknown Solana requests', async () => { + expect((await post('/v1/solana/certify', { rawTx: 'not base64', catalogKey: 'relayDepositNative' })).status).toBe(422) + expect((await post('/v1/solana/certify', { rawTx: relayLegacyTx({ wrongProgram: true }), catalogKey: 'relayDepositNative' })).status).toBe(422) + expect((await post('/v1/solana/certify', { rawTx: relayLegacyTx({ extraDataByte: true }), catalogKey: 'relayDepositNative' })).status).toBe(422) + expect((await post('/v1/solana/certify', { rawTx: relayLegacyTx(), catalogKey: 'unknown' })).status).toBe(422) + }) + + it('exposes a plain-language service page without claiming Solana transaction privacy', async () => { + const response = await fetchWorker('/') + const html = await response.text() + expect(html).toContain('unsigned transaction') + expect(html).toContain('You still approve the final transaction on the device') + expect(response.headers.get('content-security-policy')).toContain("default-src 'none'") + }) +}) diff --git a/projects/keepkey-vault/clearsign-worker/src/index.ts b/projects/keepkey-vault/clearsign-worker/src/index.ts new file mode 100644 index 00000000..49a7ad2d --- /dev/null +++ b/projects/keepkey-vault/clearsign-worker/src/index.ts @@ -0,0 +1,356 @@ +import { createHash } from 'node:crypto' +import { utils as ethersUtils } from 'ethers' +import bs58 from 'bs58' + +import { + ALPHA_DELEGATE_FINGERPRINT, + ALPHA_DELEGATE_PUBLIC_KEY, + ALPHA_ROOT_PUBLIC_KEY, + CLEARSIGN_SCOPE_ETHEREUM, + CLEARSIGN_SCOPE_SOLANA, + inspectAlphaCertificate, +} from '../../src/bun/clearsign-alpha-ceremony' +import { + buildCertifiedEvmEnvelope, + CERTIFIED_EVM_CATALOG, + CERTIFIED_METADATA_KEY_ID, + findCertifiedEvmSchemaByShape, +} from '../../src/bun/evm-certified-schema' +import { signCertifiedSolanaLutAttestation } from '../../src/bun/solana-certified-lut' +import { + CERTIFIED_SOLANA_CATALOG, + signCertifiedSolanaSchema, + solanaSchemaCoverage, +} from '../../src/bun/solana-certified-schema' +import { resolveCanonicalLutAccounts } from '../../src/bun/solana-lut-resolver' +import { createRpcAltFetcher, DEFAULT_SOLANA_RPC_ENDPOINT } from '../../src/bun/solana-alt' +import { parseSolanaMessage, parseSolanaTx, solanaMessageSlice } from '../../src/bun/solana-tx' + +interface Env { + CLEARSIGN_ENVIRONMENT?: string + CLEARSIGN_DELEGATE_PRIVATE_KEY?: string + CLEARSIGN_CERTIFICATE_HEX?: string + CLEARSIGN_SOLANA_CERTIFICATE_HEX?: string + CLEARSIGN_SOLANA_RPC_ENDPOINT?: string +} + +const SERVICE = 'KeepKey ClearSign' +const REQUEST_LIMIT = 64 * 1024 +const PROVENANCE = { + operator: 'KeepKey', + firmware: 'https://github.com/keepkey/keepkey-firmware', + vault: 'https://github.com/keepkey/keepkey-vault', + protocol: 'https://docs.relay.link/references/protocol/how-it-works', + protocolSecurity: 'https://docs.relay.link/references/protocol/security', + portals: 'https://docs.portals.fi/', + portalsRouter: 'https://eth.blockscout.com/address/0xbf5A7F3629fB325E2a8453D595AB103465F75E62?tab=contract', +} as const + +const commonHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': 'content-type', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + 'strict-transport-security': 'max-age=31536000; includeSubDomains', +} + +function json(value: unknown, status = 200, cache = 'no-store'): Response { + return new Response(JSON.stringify(value), { + status, + headers: { ...commonHeaders, 'content-type': 'application/json; charset=utf-8', 'cache-control': cache }, + }) +} + +function reviewedCatalog() { + const evm = Object.values(CERTIFIED_EVM_CATALOG).map((spec) => ({ + id: `eip155:${spec.chainId}:${spec.contract}:${spec.selector}`, + family: 'evm', + network: 'Ethereum', + protocol: spec.protocol || 'Relay', + maintainedBy: spec.maintainedBy || 'Relay', + action: spec.action || 'Deposit funds for a cross-chain swap', + method: spec.method, + contract: spec.contract, + selector: spec.selector, + ...(spec.expectedCalldataLength !== undefined + ? { calldataLength: spec.expectedCalldataLength } + : { calldataLength: { min: spec.minimumCalldataLength, max: spec.maximumCalldataLength, alignment: 'selector + ABI words' } }), + fieldsShownByKeepKey: spec.displayFields || spec.args.map((arg) => arg.name), + provenance: spec.provenance || { protocol: PROVENANCE.protocol, security: PROVENANCE.protocolSecurity }, + })) + const solana = Object.entries(CERTIFIED_SOLANA_CATALOG).map(([key, spec]) => ({ + id: `solana:${key}`, + family: 'solana', + network: 'Solana', + protocol: 'Relay', + maintainedBy: 'Relay', + action: 'Deposit funds for a cross-chain swap', + method: spec.instructionName, + program: spec.programId, + discriminator: spec.discriminator.toString('hex'), + instructionLength: solanaSchemaCoverage(spec), + fieldsShownByKeepKey: [ + ...(spec.args || []).map((arg) => arg.label), + ...(spec.accounts || []).map((account) => account.label), + ], + provenance: { protocol: PROVENANCE.protocol, security: PROVENANCE.protocolSecurity }, + })) + return [...evm, ...solana] +} + +function provisioning(env: Env) { + const issues: string[] = [] + let evmCertificate: ReturnType | undefined + let solanaCertificate: ReturnType | undefined + + const inspectScope = (value: string | undefined, scope: number, label: string) => { + if (!value) { + issues.push(`${label} certificate pending`) + return undefined + } + try { + const certificate = inspectAlphaCertificate(value) + if (certificate.chainId !== scope) throw new Error('wrong scope') + return certificate + } catch { + issues.push(`${label} certificate invalid, expired, or wrong-scope`) + return undefined + } + } + + evmCertificate = inspectScope(env.CLEARSIGN_CERTIFICATE_HEX, CLEARSIGN_SCOPE_ETHEREUM, 'Ethereum') + solanaCertificate = inspectScope(env.CLEARSIGN_SOLANA_CERTIFICATE_HEX, CLEARSIGN_SCOPE_SOLANA, 'Solana') + + let privateKeyValid = false + if (!env.CLEARSIGN_DELEGATE_PRIVATE_KEY) { + issues.push('delegate signing key pending') + } else if (!/^[0-9a-fA-F]{64}$/.test(env.CLEARSIGN_DELEGATE_PRIVATE_KEY)) { + issues.push('delegate signing key invalid') + } else { + try { + const key = new ethersUtils.SigningKey(`0x${env.CLEARSIGN_DELEGATE_PRIVATE_KEY}`) + privateKeyValid = ethersUtils.computePublicKey(key.publicKey, true).slice(2).toLowerCase() === ALPHA_DELEGATE_PUBLIC_KEY + if (!privateKeyValid) issues.push('delegate signing key does not match reviewed fingerprint') + } catch { + issues.push('delegate signing key invalid') + } + } + + return { + ready: Boolean((evmCertificate || solanaCertificate) && privateKeyValid), + evmReady: Boolean(evmCertificate && privateKeyValid), + solanaReady: Boolean(solanaCertificate && privateKeyValid), + evmCertificate, + solanaCertificate, + privateKeyValid, + issues, + } +} + +function publicStatus(env: Env, origin: string) { + const state = provisioning(env) + const expires = [state.evmCertificate?.notAfter, state.solanaCertificate?.notAfter].filter(Boolean) as number[] + return { + service: SERVICE, + environment: env.CLEARSIGN_ENVIRONMENT || 'production', + status: state.ready ? 'ready' : 'provisioning', + message: state.ready + ? 'KeepKey can authenticate transaction descriptions for every scope marked ready below, without blind signing.' + : 'The service is online, but no certified signing scope is active yet.', + endpoints: { + status: `${origin}/v1/status`, + catalog: `${origin}/v1/catalog`, + evmSchema: `${origin}/v1/evm/schema`, + solanaCertify: `${origin}/v1/solana/certify`, + }, + scopes: { + ethereum: state.evmReady ? 'ready' : 'provisioning', + solana: state.solanaReady ? 'ready' : 'provisioning', + }, + trust: { + label: state.ready ? 'Authenticated by KeepKey' : 'Certificate pending', + signerAlias: state.evmCertificate?.alias || state.solanaCertificate?.alias || 'KeepKey Vault', + signerFingerprint: ALPHA_DELEGATE_FINGERPRINT, + signerPublicKey: ALPHA_DELEGATE_PUBLIC_KEY, + rootPublicKey: ALPHA_ROOT_PUBLIC_KEY, + certificateExpiresAt: expires.length ? new Date(Math.min(...expires) * 1000).toISOString() : null, + deviceChecksCertificate: true, + deviceChecksTransactionBinding: true, + }, + privacy: { + applicationStorage: false, + ethereumRequest: ['chainId', 'contract', 'selector', 'calldataLength'], + solanaRequest: ['unsigned transaction', 'reviewed catalog id'], + note: 'Solana lookup-table certification sends the unsigned transaction to this service so it can resolve and bind the exact accounts. No seed, private key, PIN, passphrase, or device signature is sent.', + }, + catalogEntries: reviewedCatalog().length, + provisioning: state.issues, + provenance: PROVENANCE, + } +} + +function escapeHtml(value: unknown): string { + return String(value).replace(/[&<>"']/g, (character) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]!) +} + +function home(env: Env, origin: string): Response { + const status = publicStatus(env, origin) + const ready = status.status === 'ready' + const html = ` +${SERVICE} +
${escapeHtml(status.status)}

KeepKey ClearSign

Human-readable transaction details, authenticated by the KeepKey in your hand.

+

What happens

${escapeHtml(status.message)}

The service recognizes a reviewed protocol action and signs a description. Your KeepKey independently checks the root certificate, signer fingerprint, program or contract, decoded fields, and the exact transaction binding. You still approve the final transaction on the device.

+

Trust status

Device label
${escapeHtml(status.trust.label)}
Signer
${escapeHtml(status.trust.signerAlias)} · ${escapeHtml(status.trust.signerFingerprint)}
Ethereum
${escapeHtml(status.scopes.ethereum)}
Solana
${escapeHtml(status.scopes.solana)}
Earliest certificate expiry
${escapeHtml(status.trust.certificateExpiresAt || 'Pending')}
+

Reviewed protocols

Relay · Ethereum and Solana deposits for cross-chain swaps.

Portals · Native ETH swaps through the verified Ethereum router. KeepKey reads the output token, minimum output, recipient, and input amount from the transaction itself.

Only exact catalog matches are certified. Unknown programs, contracts, selectors, instruction sizes, or lookup-table accounts are refused.

View the machine-readable catalog
+

Privacy and provenance

Ethereum requests contain only transaction shape. Solana lookup-table requests contain the unsigned transaction so this service can resolve and bind its accounts. Wallet seeds, private keys, PINs, passphrases, and device signatures never leave your KeepKey. This service writes no transaction database.

How Relay works · Relay security · Portals documentation · Verified Portals router · KeepKey firmware · Vault source

+
` + return new Response(html, { + headers: { + ...commonHeaders, + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + }, + }) +} + +async function readJson(request: Request): Promise { + const contentLength = Number(request.headers.get('content-length') || 0) + if (!Number.isFinite(contentLength) || contentLength > REQUEST_LIMIT) throw new Error('request too large') + const text = await request.text() + if (text.length > REQUEST_LIMIT) throw new Error('request too large') + try { + return JSON.parse(text) + } catch { + throw new Error('invalid JSON') + } +} + +function decodeCanonicalBase64(value: unknown): Buffer { + const encoded = String(value || '') + if (!encoded || encoded.length > REQUEST_LIMIT || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) { + throw new Error('rawTx must be canonical base64') + } + const decoded = Buffer.from(encoded, 'base64') + if (!decoded.length || decoded.toString('base64') !== encoded) throw new Error('rawTx must be canonical base64') + return decoded +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: commonHeaders }) + if (request.method === 'GET' && url.pathname === '/') return home(env, url.origin) + if (request.method === 'GET' && url.pathname === '/health') { + const state = provisioning(env) + return json({ ok: true, ready: state.ready, service: 'keepkey-clearsign', fingerprint: ALPHA_DELEGATE_FINGERPRINT }) + } + if (request.method === 'GET' && (url.pathname === '/ready' || url.pathname === '/v1/status')) { + const status = publicStatus(env, url.origin) + return json(status, url.pathname === '/ready' && status.status !== 'ready' ? 503 : 200) + } + if (request.method === 'GET' && url.pathname === '/v1/catalog') { + return json({ version: 1, entries: reviewedCatalog(), provenance: PROVENANCE }, 200, 'public, max-age=300') + } + if (request.method === 'GET' && url.pathname === '/signer') { + const status = publicStatus(env, url.origin) + return json({ status: status.status, alias: status.trust.signerAlias, fingerprint: ALPHA_DELEGATE_FINGERPRINT, publicKeyHex: ALPHA_DELEGATE_PUBLIC_KEY, keyId: CERTIFIED_METADATA_KEY_ID, scopes: status.scopes, certificateExpiresAt: status.trust.certificateExpiresAt }) + } + + if (request.method === 'POST' && (url.pathname === '/v1/evm/schema' || url.pathname === '/sign')) { + let body: any + try { body = await readJson(request) } catch (error: any) { + return json({ error: error.message }, error.message === 'request too large' ? 413 : 400) + } + const spec = findCertifiedEvmSchemaByShape(Number(body?.chainId), String(body?.contract || body?.to || ''), String(body?.selector || ''), Number(body?.calldataLength)) + if (!spec) return json({ classification: 'OPAQUE', error: 'contract, selector, or calldata shape is not in the reviewed catalog' }, 422) + const state = provisioning(env) + if (!state.evmReady || !env.CLEARSIGN_CERTIFICATE_HEX || !env.CLEARSIGN_DELEGATE_PRIVATE_KEY) { + return json({ classification: 'UNAVAILABLE', error: 'Ethereum certified signing is not provisioned' }, 503) + } + try { + const signed = buildCertifiedEvmEnvelope(spec, env.CLEARSIGN_CERTIFICATE_HEX, env.CLEARSIGN_DELEGATE_PRIVATE_KEY) + return json({ success: true, classification: 'VERIFIED', version: 3, ...signed, method: spec.method, chainId: spec.chainId, contract: spec.contract, selector: spec.selector, expectedCalldataLength: spec.expectedCalldataLength, decoder: spec.decoder, provenance: spec.provenance || PROVENANCE }) + } catch { + return json({ error: 'certified Ethereum schema could not be produced' }, 500) + } + } + + if (request.method === 'POST' && url.pathname === '/v1/solana/certify') { + let body: any + try { body = await readJson(request) } catch (error: any) { + return json({ error: error.message }, error.message === 'request too large' ? 413 : 400) + } + const catalogKey = String(body?.catalogKey || '') + const spec = CERTIFIED_SOLANA_CATALOG[catalogKey] + if (!spec) return json({ classification: 'OPAQUE', error: 'catalogKey is not in the reviewed catalog' }, 422) + + let fullTx: Buffer + let messageBytes: Uint8Array + let message: ReturnType + try { + fullTx = decodeCanonicalBase64(body?.rawTx) + const parsedTx = parseSolanaTx(fullTx) + messageBytes = solanaMessageSlice(fullTx, parsedTx) + message = parseSolanaMessage(messageBytes) + } catch (error: any) { + return json({ classification: 'OPAQUE', error: error?.message || 'malformed Solana transaction' }, 422) + } + + const programBytes = Buffer.from(bs58.decode(spec.programId)) + const expectedLength = solanaSchemaCoverage(spec) + const matchesInstruction = message.instructions.some((instruction) => { + const programKey = message.staticAccounts[instruction.programIdIndex] + if (!programKey || !Buffer.from(programKey).equals(programBytes)) return false + if ((spec.accounts || []).some((account) => account.index >= instruction.accountIndices.length)) return false + const data = Buffer.from(instruction.data) + return data.length === expectedLength && data.subarray(0, spec.discriminator.length).equals(spec.discriminator) + }) + if (!matchesInstruction) { + return json({ classification: 'OPAQUE', error: `catalog entry ${catalogKey} does not exactly match an instruction in this transaction` }, 422) + } + + const state = provisioning(env) + if (!state.solanaReady || !env.CLEARSIGN_SOLANA_CERTIFICATE_HEX || !env.CLEARSIGN_DELEGATE_PRIVATE_KEY) { + return json({ classification: 'UNAVAILABLE', error: 'Solana certified signing is not provisioned' }, 503) + } + + try { + const schema = signCertifiedSolanaSchema(env.CLEARSIGN_SOLANA_CERTIFICATE_HEX, env.CLEARSIGN_DELEGATE_PRIVATE_KEY, spec) + const response: any = { + success: true, + classification: 'VERIFIED', + schema: { payload: schema.schemaPayload, signature: schema.schemaSignature, signerKeyId: schema.keyId }, + certificate: `0x${env.CLEARSIGN_SOLANA_CERTIFICATE_HEX.replace(/^0x/i, '')}`, + alias: schema.alias, + fingerprint: schema.fingerprint, + transactionShape: message.version, + lookupTableCount: message.altEntries.length, + provenance: PROVENANCE, + } + if (message.altEntries.length === 0) return json(response) + + const resolution = await resolveCanonicalLutAccounts( + message, + createRpcAltFetcher(env.CLEARSIGN_SOLANA_RPC_ENDPOINT || DEFAULT_SOLANA_RPC_ENDPOINT), + ) + const messageHash = createHash('sha256').update(messageBytes).digest() + const proof = signCertifiedSolanaLutAttestation(env.CLEARSIGN_SOLANA_CERTIFICATE_HEX, env.CLEARSIGN_DELEGATE_PRIVATE_KEY, messageHash, resolution.accounts) + response.lutProof = { + accounts: resolution.accounts.map((account) => account.toString('base64')), + signature: proof.lutSignature, + signerKeyId: proof.keyId, + } + response.writableCount = resolution.writableCount + response.readonlyCount = resolution.readonlyCount + return json(response) + } catch { + return json({ error: 'certified Solana proof could not be produced' }, 500) + } + } + return json({ error: 'not found' }, 404) + }, +} diff --git a/projects/keepkey-vault/clearsign-worker/wrangler.toml b/projects/keepkey-vault/clearsign-worker/wrangler.toml new file mode 100644 index 00000000..8c594e35 --- /dev/null +++ b/projects/keepkey-vault/clearsign-worker/wrangler.toml @@ -0,0 +1,13 @@ +name = "keepkey-clearsign" +main = "src/index.ts" +account_id = "53185d258559a5dae7d6fa6225cc46ba" +compatibility_date = "2026-08-23" +compatibility_flags = ["nodejs_compat"] +workers_dev = true + +[vars] +CLEARSIGN_ENVIRONMENT = "production" +CLEARSIGN_SOLANA_RPC_ENDPOINT = "https://api.mainnet-beta.solana.com" + +[observability] +enabled = true diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index 92546dc3..9fac2dcd 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -1,10 +1,21 @@ import type { ElectrobunConfig } from "electrobun"; +import { existsSync } from "node:fs"; import pkg from "./package.json"; const isWindows = process.platform === "win32"; const isMac = process.platform === "darwin"; const arch = process.arch; // 'arm64' or 'x64' if (isMac) console.log(`[electrobun] Building for macOS ${arch}`); +const certifiedEmulatorSource = isWindows + ? "emulator-bundle/libkkemu.dll" + : isMac ? "emulator-bundle/libkkemu.dylib" : null; +// Local Developer-ID builds stage a signed copy after verifying the immutable +// certified artifact. CI/unsigned builds continue to embed the certified bytes +// directly. Keeping the signed copy separate preserves source hash verification. +const emulatorSource = process.env.KEEPKEY_EMULATOR_SOURCE || certifiedEmulatorSource; +const emulatorCopy = emulatorSource && existsSync(emulatorSource) + ? { [emulatorSource]: `emulator/${emulatorSource.split('/').pop()}` } + : {}; export default { app: { @@ -50,6 +61,9 @@ export default { // so tampering with any binary breaks Apple's signature. Provides an offline // floor when the remote manifest is unreachable. See firmware-bundle/README.md. "firmware-bundle": "firmware-bundle", + // Release builds preflight this file before Electrobun runs. Keeping the + // config conditional preserves emulator-free developer builds on Linux. + ...emulatorCopy, // Zcash privacy engine sidecar (Rust binary -- .exe on Windows) [isWindows ? "zcash-cli/target/release/zcash-cli.exe" : "zcash-cli/target/release/zcash-cli"]: isWindows ? "zcash-cli.exe" : "zcash-cli", }, diff --git a/projects/keepkey-vault/emulator-bundle/README.md b/projects/keepkey-vault/emulator-bundle/README.md new file mode 100644 index 00000000..fffcf872 --- /dev/null +++ b/projects/keepkey-vault/emulator-bundle/README.md @@ -0,0 +1,11 @@ +# Bundled KeepKey emulator + +Release builds stage the firmware shared libraries here; the binaries are build +artifacts and are intentionally gitignored. + +- `libkkemu.dylib`: universal macOS arm64 + x86_64 +- `libkkemu.dll`: Windows x86_64 + +Both are built from the `modules/keepkey-firmware` gitlink, must report firmware +7.16.0, include the ClearSign alpha root, and expose the complete Vault FFI ABI. +Run `make build-emulator-release` on macOS to rebuild and verify both. diff --git a/projects/keepkey-vault/emulator-bundle/manifest.json b/projects/keepkey-vault/emulator-bundle/manifest.json new file mode 100644 index 00000000..23904349 --- /dev/null +++ b/projects/keepkey-vault/emulator-bundle/manifest.json @@ -0,0 +1,38 @@ +{ + "version": "7.16.0", + "source": { + "repository": "keepkey/keepkey-vault", + "runId": 33047449262, + "headSha": "7f802ba261fadebe71ee794042d0ac89d564a82d", + "artifactId": 9636414855, + "artifactName": "keepkey-vault-macos-7f802ba261fadebe71ee794042d0ac89d564a82d", + "artifactDigest": "sha256:62f2f4a829290552b1f6d45260644f9e6227ff3bac9d4ff2af51098db9d3112d", + "macArchive": "stable-macos-x64-keepkey-vault.app.tar.zst", + "macLibraryPath": "keepkey-vault.app/Contents/Resources/app/emulator/libkkemu.dylib", + "windowsLibrary": "emulator-build-input-libkkemu-7.16.0-win-x64.dll" + }, + "files": { + "libkkemu.dylib": { + "sha256": "4494fb6494a9340f6bf7eec9920fdafcd48ab9f8839e9ef2b60238ff336e55a3", + "architectures": ["arm64", "x86_64"] + }, + "libkkemu.dll": { + "sha256": "611d6b51427c0d22eaac568ebc10405bab353e60805205000f808466f0ea5354", + "architecture": "x86_64" + } + }, + "requiredSymbols": [ + "kkemu_init", + "kkemu_shutdown", + "kkemu_write", + "kkemu_read", + "kkemu_poll", + "kkemu_is_running", + "kkemu_pop_frame", + "kkemu_start", + "kkemu_stop", + "kkemu_lock", + "kkemu_unlock", + "kkemu_trylock" + ] +} diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 62cc1dfb..20d49cdc 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.5.3", + "version": "1.5.4", "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", @@ -11,6 +11,9 @@ "build:stable": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && bun scripts/build-signed.ts stable", "build:canary": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && bun scripts/build-signed.ts canary", "assets:vendor-icons": "bun scripts/vendor-asset-icons.ts", + "clearsign:worker:test": "bun test clearsign-worker/src/index.test.ts", + "clearsign:worker:dev": "wrangler dev --config clearsign-worker/wrangler.toml", + "clearsign:worker:deploy": "wrangler deploy --config clearsign-worker/wrangler.toml", "start": "bun run dev", "postinstall": "bash scripts/patch-electrobun.sh" }, diff --git a/projects/keepkey-vault/scripts/build-signed.ts b/projects/keepkey-vault/scripts/build-signed.ts index 0a012b3b..20a6e8ce 100644 --- a/projects/keepkey-vault/scripts/build-signed.ts +++ b/projects/keepkey-vault/scripts/build-signed.ts @@ -8,10 +8,46 @@ * Our shim at scripts/zip adds -q (quiet) to suppress per-file output. */ import { join } from 'node:path' +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' const env = process.argv[2] || 'stable' const scriptsDir = join(import.meta.dir) const currentPath = process.env.PATH || '' +let emulatorSource: string | undefined + +// Stable/canary packages for supported emulator hosts must never silently ship +// without the pinned library. Developer `bun run build` remains optional. +if (process.platform === 'darwin' || process.platform === 'win32') { + const lib = process.platform === 'win32' ? 'libkkemu.dll' : 'libkkemu.dylib' + const staged = join(import.meta.dir, '..', 'emulator-bundle', lib) + if (!existsSync(staged)) { + console.error(`[release] Missing bundled emulator: ${staged}`) + console.error('[release] Stage the certified emulator artifact with scripts/stage-certified-emulator.sh .') + process.exit(1) + } + + // Apple notarization requires every embedded Mach-O to carry a timestamped + // Developer ID signature. Verify-certified-emulator runs before this wrapper + // in the release target, so sign a disposable copy and leave the immutable + // certified artifact untouched for reproducible hash checks. + if (process.platform === 'darwin' && process.env.CI !== 'true') { + const developer = process.env.ELECTROBUN_DEVELOPER_ID + const team = process.env.ELECTROBUN_TEAMID + if (!developer || !team) { + console.error('[release] ELECTROBUN_DEVELOPER_ID and ELECTROBUN_TEAMID are required for the emulator signature.') + process.exit(1) + } + emulatorSource = join('_build', '_signed_emulator', lib) + const signedCopy = join(import.meta.dir, '..', emulatorSource) + mkdirSync(join(import.meta.dir, '..', '_build', '_signed_emulator'), { recursive: true }) + copyFileSync(staged, signedCopy) + const signed = Bun.spawnSync([ + 'codesign', '--force', '--verbose', '--timestamp', '--options', 'runtime', + '--sign', `Developer ID Application: ${developer} (${team})`, signedCopy, + ], { stdout: 'inherit', stderr: 'inherit' }) + if (signed.exitCode !== 0) process.exit(signed.exitCode ?? 1) + } +} const result = Bun.spawnSync( ['electrobun', 'build', `--env=${env}`], @@ -20,6 +56,7 @@ const result = Bun.spawnSync( env: { ...process.env, PATH: `${scriptsDir}:${currentPath}`, + ...(emulatorSource ? { KEEPKEY_EMULATOR_SOURCE: emulatorSource } : {}), }, stdout: 'inherit', stderr: 'inherit', diff --git a/projects/keepkey-vault/scripts/clearsign-live-signer.ts b/projects/keepkey-vault/scripts/clearsign-live-signer.ts new file mode 100644 index 00000000..1860ddd9 --- /dev/null +++ b/projects/keepkey-vault/scripts/clearsign-live-signer.ts @@ -0,0 +1,183 @@ +/** Local 7.16 ClearSign service. The delegate key never enters Vault. */ +import { createHash } from 'node:crypto' +import bs58 from 'bs58' +import { + ALPHA_DELEGATE_FINGERPRINT, + ALPHA_DELEGATE_PUBLIC_KEY, + CLEARSIGN_SCOPE_SOLANA, + inspectAlphaCertificate, +} from '../src/bun/clearsign-alpha-ceremony' +import { signCertifiedSolanaLutAttestation } from '../src/bun/solana-certified-lut' +import { signCertifiedSolanaSchema, CERTIFIED_SOLANA_CATALOG } from '../src/bun/solana-certified-schema' +import { resolveCanonicalLutAccounts } from '../src/bun/solana-lut-resolver' +import { createRpcAltFetcher, DEFAULT_SOLANA_RPC_ENDPOINT } from '../src/bun/solana-alt' +import { parseSolanaTx, solanaMessageSlice, parseSolanaMessage } from '../src/bun/solana-tx' + +interface SignerFile { + alias: string + fingerprint: string + publicKeyHex: string + privateKeyHex: string +} + +const keyFile = process.env.CLEARSIGN_SIGNER_KEY_FILE +if (!keyFile) throw new Error('CLEARSIGN_SIGNER_KEY_FILE is required') +const signer = await Bun.file(keyFile).json() as SignerFile +if (signer.fingerprint !== ALPHA_DELEGATE_FINGERPRINT || signer.publicKeyHex.toLowerCase() !== ALPHA_DELEGATE_PUBLIC_KEY) { + throw new Error(`signer file does not match reviewed delegate ${ALPHA_DELEGATE_FINGERPRINT}`) +} +if (!/^[0-9a-fA-F]{64}$/.test(String(signer.privateKeyHex || ''))) { + throw new Error('signer file does not contain a 32-byte private key') +} + +async function loadCertificateHex(hexEnv: string, fileEnv: string): Promise { + let hex = process.env[hexEnv] + if (!hex && process.env[fileEnv]) { + hex = (await Bun.file(process.env[fileEnv]!).text()).trim() + } + if (!hex) return undefined + if (hex.trim().startsWith('{')) { + const parsed = JSON.parse(hex) + hex = String(parsed?.certificateHex || '') + } + return hex.replace(/^0x/i, '') +} + +const solanaCertificateHex = await loadCertificateHex('CLEARSIGN_SOLANA_CERTIFICATE_HEX', 'CLEARSIGN_SOLANA_CERTIFICATE_FILE') +if (!solanaCertificateHex) throw new Error('CLEARSIGN_SOLANA_CERTIFICATE_HEX/FILE is required') +const solanaCertificate = solanaCertificateHex ? inspectAlphaCertificate(solanaCertificateHex) : undefined +if (solanaCertificate && solanaCertificate.chainId !== CLEARSIGN_SCOPE_SOLANA) { + throw new Error(`CLEARSIGN_SOLANA_CERTIFICATE_HEX is scoped to ${solanaCertificate.chainId}, expected Solana (${CLEARSIGN_SCOPE_SOLANA})`) +} + +const solanaRpcEndpoint = process.env.CLEARSIGN_SOLANA_RPC_ENDPOINT || DEFAULT_SOLANA_RPC_ENDPOINT + +const port = Number(process.env.CLEARSIGN_SIGNER_PORT || 1647) +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid CLEARSIGN_SIGNER_PORT') + +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, +}) + +const server = Bun.serve({ + hostname: '127.0.0.1', + port, + async fetch(request) { + const url = new URL(request.url) + if (request.method === 'GET' && url.pathname === '/health') { + return json({ ok: true, service: 'keepkey-clearsign', fingerprint: ALPHA_DELEGATE_FINGERPRINT }) + } + if (request.method === 'GET' && url.pathname === '/signer') { + return json({ + alias: solanaCertificate?.alias, + fingerprint: signer.fingerprint, + publicKeyHex: signer.publicKeyHex, + keyId: 0x80, + scopes: [CLEARSIGN_SCOPE_SOLANA], + }) + } + if (request.method === 'POST' && (url.pathname === '/v1/evm/schema' || url.pathname === '/sign')) { + return json({ error: 'this signer is scoped to Solana only' }, 501) + } + if (request.method === 'POST' && url.pathname === '/v1/solana/certify') { + if (!solanaCertificateHex) return json({ error: 'this signer has no Solana-scoped certificate loaded' }, 501) + const contentLength = Number(request.headers.get('content-length') || 0) + if (contentLength > 64 * 1024) return json({ error: 'request too large' }, 413) + let body: any + try { + body = await request.json() + } catch { + return json({ error: 'invalid JSON' }, 400) + } + const rawTxBase64 = String(body?.rawTx || '') + const catalogKey = String(body?.catalogKey || '') + const spec = (CERTIFIED_SOLANA_CATALOG as any)[catalogKey] + if (!rawTxBase64 || !spec) { + return json({ error: 'rawTx and a recognized catalogKey are required' }, 400) + } + try { + const fullTx = Buffer.from(rawTxBase64, 'base64') + const parsedTx = parseSolanaTx(fullTx) + const messageBytes = solanaMessageSlice(fullTx, parsedTx) + const message = parseSolanaMessage(messageBytes) + + // Refuse to sign unless the exact instruction this schema describes + // is actually present — a schema is never a blank check for "trust + // whatever program this transaction touches". + const programBytes = Buffer.from(bs58.decode(spec.programId)) + const matchesInstruction = message.instructions.some((ix) => { + const programKey = message.staticAccounts[ix.programIdIndex] + if (!programKey || !Buffer.from(programKey).equals(programBytes)) return false + const data = Buffer.from(ix.data) + return data.length >= spec.discriminator.length + && data.subarray(0, spec.discriminator.length).equals(Buffer.from(spec.discriminator)) + }) + if (!matchesInstruction) { + return json({ error: `catalog entry ${catalogKey} does not match any instruction in this transaction` }, 422) + } + + const schema = signCertifiedSolanaSchema(solanaCertificateHex, signer.privateKeyHex, spec) + + // A self-contained legacy/v0 message commits every instruction account + // directly in the signed bytes. It needs the certified instruction + // schema, but there is no external account-resolution claim to sign. + // Return no lutProof at all: an empty proof would be a different wire + // statement and could hide accidental coupling between certification + // and Relay's current transaction compiler. + if (message.altEntries.length === 0) { + return json({ + success: true, + classification: 'VERIFIED', + schema: { + payload: schema.schemaPayload, + signature: schema.schemaSignature, + signerKeyId: schema.keyId, + }, + certificate: `0x${solanaCertificateHex}`, + alias: schema.alias, + fingerprint: schema.fingerprint, + transactionShape: message.version, + lookupTableCount: 0, + }) + } + + const lutResolution = await resolveCanonicalLutAccounts(message, createRpcAltFetcher(solanaRpcEndpoint)) + const messageHash = createHash('sha256').update(messageBytes).digest() + const lutProof = signCertifiedSolanaLutAttestation( + solanaCertificateHex, + signer.privateKeyHex, + messageHash, + lutResolution.accounts, + ) + return json({ + success: true, + classification: 'VERIFIED', + lutProof: { + accounts: lutResolution.accounts.map((a) => a.toString('base64')), + signature: lutProof.lutSignature, + signerKeyId: lutProof.keyId, + }, + schema: { + payload: schema.schemaPayload, + signature: schema.schemaSignature, + signerKeyId: schema.keyId, + }, + certificate: `0x${solanaCertificateHex}`, + alias: lutProof.alias, + fingerprint: lutProof.fingerprint, + writableCount: lutResolution.writableCount, + readonlyCount: lutResolution.readonlyCount, + transactionShape: message.version, + lookupTableCount: message.altEntries.length, + }) + } catch (error: any) { + return json({ error: error?.message || 'could not build certified Solana proof' }, 500) + } + } + return json({ error: 'not found' }, 404) + }, +}) + +console.log(`[clearsign] local signer ready at http://${server.hostname}:${server.port}`) +console.log(`[clearsign] delegate ${solanaCertificate?.alias} · ${signer.fingerprint} (scopes: solana)`) diff --git a/projects/keepkey-vault/scripts/prune-app-bundle.ts b/projects/keepkey-vault/scripts/prune-app-bundle.ts index 8f4a298d..93d50136 100644 --- a/projects/keepkey-vault/scripts/prune-app-bundle.ts +++ b/projects/keepkey-vault/scripts/prune-app-bundle.ts @@ -450,6 +450,26 @@ if (existsSync(infoPlistPath)) { } } +// Emulator releases are a two-architecture contract: CI converts this arm64 +// app archive into the Intel archive later, so a thin dylib here would produce +// an Intel app that installs successfully but fails at dlopen time. +const bundledEmulator = join(resourcesDir, 'app', 'emulator', 'libkkemu.dylib') +if (!existsSync(bundledEmulator)) { + console.error(`[prune-bundle] ERROR: bundled 7.16 emulator missing: ${bundledEmulator}`) + process.exit(1) +} +const emuArchs = Bun.spawnSync(['lipo', '-archs', bundledEmulator]) +if (emuArchs.exitCode !== 0) { + console.error(`[prune-bundle] ERROR: cannot inspect bundled emulator: ${emuArchs.stderr.toString()}`) + process.exit(1) +} +const emuArchText = emuArchs.stdout.toString().trim() +if (!emuArchText.includes('arm64') || !emuArchText.includes('x86_64')) { + console.error(`[prune-bundle] ERROR: bundled emulator is not universal: ${emuArchText}`) + process.exit(1) +} +console.log(`[prune-bundle] Verified bundled emulator architectures: ${emuArchText}`) + // Re-sign native binaries after pruning (signatures may have been invalidated) const DEVELOPER_ID = process.env.ELECTROBUN_DEVELOPER_ID const TEAM_ID = process.env.ELECTROBUN_TEAMID @@ -474,7 +494,7 @@ if (DEVELOPER_ID && TEAM_ID) { } } catch {} } - signBinaries(nmDir) + signBinaries(resourcesDir) console.log(`[prune-bundle] Re-signed ${signedCount} native binaries`) } diff --git a/projects/keepkey-vault/src/bun/btc-accounts.ts b/projects/keepkey-vault/src/bun/btc-accounts.ts index 8375341f..c2e708cd 100644 --- a/projects/keepkey-vault/src/bun/btc-accounts.ts +++ b/projects/keepkey-vault/src/bun/btc-accounts.ts @@ -45,29 +45,69 @@ export class BtcAccountManager extends EventEmitter { return set } - /** Fetch supported xpubs for a given account index in a single batch device call. */ + /** Fetch the three established xpubs first, then optional capabilities. + * + * Taproot is deliberately isolated from the required batch. A parent-repo + * submodule rollback once paired Vault's P2TR discovery with an older + * hdwallet translator: the adapter claimed P2TR support, then threw while + * encoding it. Because all four paths shared one getPublicKeys call, that + * optional failure erased the historical three accounts and the selector + * rendered as an unexplained blank. Required account types fail loudly; + * optional account types degrade without taking Bitcoin offline. */ private async fetchAccount(wallet: any, accountIndex: number): Promise { // Safety: skip if this account index already exists (prevents race-condition duplicates) if (this.accounts.some(a => a.accountIndex === accountIndex)) return - const scriptTypes = await supportedBtcScriptTypes(wallet) - const paths = scriptTypes.map(st => ({ + const supportedScriptTypes = await supportedBtcScriptTypes(wallet) + const requiredScriptTypes = supportedScriptTypes.filter(st => st.scriptType !== 'p2tr') + const optionalScriptTypes = supportedScriptTypes.filter(st => st.scriptType === 'p2tr') + const requiredPaths = requiredScriptTypes.map(st => ({ addressNList: btcAccountPath(st.purpose, accountIndex), coin: 'Bitcoin', scriptType: st.scriptType, curve: 'secp256k1', })) - const results = await wallet.getPublicKeys(paths) + const requiredResults = await wallet.getPublicKeys(requiredPaths) + const missingRequired = requiredScriptTypes + .filter((_, i) => !requiredResults?.[i]?.xpub) + .map(st => st.scriptType) + if (missingRequired.length > 0) { + throw new Error( + `Bitcoin account ${accountIndex} xpub derivation incomplete; missing required script types: ${missingRequired.join(', ')}`, + ) + } + + const derived: Array<{ config: (typeof supportedScriptTypes)[number]; result: any }> = + requiredScriptTypes.map((config, i) => ({ config, result: requiredResults[i] })) + + for (const config of optionalScriptTypes) { + const path = { + addressNList: btcAccountPath(config.purpose, accountIndex), + coin: 'Bitcoin', + scriptType: config.scriptType, + curve: 'secp256k1', + } + try { + const optionalResults = await wallet.getPublicKeys([path]) + if (!optionalResults?.[0]?.xpub) { + console.warn(`[btc-accounts] Optional ${config.scriptType} xpub was not returned for account ${accountIndex}; continuing with required Bitcoin account types`) + continue + } + derived.push({ config, result: optionalResults[0] }) + } catch (e: any) { + console.warn(`[btc-accounts] Optional ${config.scriptType} xpub derivation failed for account ${accountIndex}; continuing with required Bitcoin account types: ${e?.message || String(e)}`) + } + } // Re-check after await (another call may have added it while we were waiting) if (this.accounts.some(a => a.accountIndex === accountIndex)) return - const xpubs: BtcXpub[] = scriptTypes.map((st, i) => ({ + const xpubs: BtcXpub[] = derived.map(({ config: st, result }) => ({ scriptType: st.scriptType, purpose: st.purpose, path: btcAccountPath(st.purpose, accountIndex), - xpub: results?.[i]?.xpub || '', + xpub: result.xpub, xpubPrefix: st.xpubPrefix, balance: '0', balanceUsd: 0, @@ -117,24 +157,37 @@ export class BtcAccountManager extends EventEmitter { return account?.xpubs.find(x => x.scriptType === this.selectedXpub.scriptType) } - /** Get ALL xpubs with non-zero balance (for UTXO aggregation in sends/swaps). */ - getFundedXpubs(): Array<{ xpub: string; scriptType: string; accountPath: number[] }> { + /** Every xpub, for UTXO aggregation in sends/swaps. + * + * This used to filter on `parseFloat(xp.balance) > 0` and was named + * getFundedXpubs. `xp.balance` is the CACHED balance, and a cached zero is + * not proof of an empty account — it is also what a chain whose balance + * fetch failed looks like. Filtering here dropped that account before the + * builder could see it, so buildUtxoTx's every-lookup-succeeded check + * (`unreachableXpubs`) stayed at zero and a MAX swept a subset of the + * wallet while believing it had swept all of it. Same bypass shape as the + * frontend `tokenBalance: '0'` one, a layer further upstream. + * + * The cost of dropping the filter is one ListUnspent per genuinely empty + * xpub, which returns [] and adds nothing. The builder decides what is + * spendable; this method's job is only to say what exists. */ + getSpendableXpubs(): Array<{ xpub: string; scriptType: string; accountPath: number[] }> { const result: Array<{ xpub: string; scriptType: string; accountPath: number[] }> = [] for (const account of this.accounts) { for (const xp of account.xpubs) { - if (xp.xpub && parseFloat(xp.balance) > 0) { + if (xp.xpub) { result.push({ xpub: xp.xpub, scriptType: xp.scriptType, accountPath: xp.path }) } } } - const all = this.accounts.flatMap(a => a.xpubs).filter(x => x.xpub) - console.log(`[btc-accounts] getFundedXpubs: ${result.length}/${all.length} funded — ${all.map(x => `${x.scriptType}=${x.balance}`).join(', ')}`) + const funded = this.accounts.flatMap(a => a.xpubs).filter(x => x.xpub && parseFloat(x.balance) > 0) + console.log(`[btc-accounts] getSpendableXpubs: ${result.length} xpubs (${funded.length} with a cached non-zero balance) — ${result.length ? this.accounts.flatMap(a => a.xpubs).filter(x => x.xpub).map(x => `${x.scriptType}=${x.balance}`).join(', ') : 'none'}`) return result } /** All xpubs across all accounts with derivation metadata — used to seed - * own-wallet Address Book entries (R2). Unlike getFundedXpubs() this includes - * unfunded xpubs so a fresh wallet still appears in the book. */ + * own-wallet Address Book entries (R2). Unlike getSpendableXpubs() this + * carries accountIndex, which the book needs for labelling. */ getAllXpubMeta(): Array<{ xpub: string; scriptType: BtcScriptType; accountIndex: number; path: number[] }> { const out: Array<{ xpub: string; scriptType: BtcScriptType; accountIndex: number; path: number[] }> = [] for (const account of this.accounts) { diff --git a/projects/keepkey-vault/src/bun/clearsign-alpha-ceremony.test.ts b/projects/keepkey-vault/src/bun/clearsign-alpha-ceremony.test.ts new file mode 100644 index 00000000..0d3b20a8 --- /dev/null +++ b/projects/keepkey-vault/src/bun/clearsign-alpha-ceremony.test.ts @@ -0,0 +1,71 @@ +import { utils as ethersUtils } from 'ethers' + +import { + ALPHA_DELEGATE_PUBLIC_KEY, + CLEARSIGN_DOMAIN_SEPARATOR, + buildAlphaCertificateBody, + inspectAlphaCertificateBody, +} from './clearsign-alpha-ceremony' + +function bodyHex(overrides: { flags?: number; chain?: number; expiry?: number; delegate?: string } = {}): string { + const body = Buffer.alloc(75) + body[0] = 1 + body[1] = overrides.flags ?? 1 + body.writeUInt32BE(overrides.chain ?? 1, 2) + body.writeUInt32BE(overrides.expiry ?? 1818806400, 6) + body.write('KeepKey Alpha 716', 10, 'ascii') + Buffer.from(overrides.delegate ?? ALPHA_DELEGATE_PUBLIC_KEY, 'hex').copy(body, 42) + return body.toString('hex') +} + +describe('7.16 alpha certificate ceremony', () => { + it('builds the canonical body for the reviewed alpha delegate', () => { + const built = buildAlphaCertificateBody('KeepKey Vault', 1818806400) + const inspected = inspectAlphaCertificateBody( + built.signedBodyHex, + built.expectedMessageHashHex, + 1787500000, + ) + expect(Buffer.from(built.signedBodyHex, 'hex').length).toBe(75) + expect(inspected.alias).toBe('KeepKey Vault') + expect(inspected.chainId).toBe(1) + expect(inspected.notAfter).toBe(1818806400) + expect(inspected.delegatePublicKey).toBe(ALPHA_DELEGATE_PUBLIC_KEY) + }) + + it('rejects aliases the OLED cannot render honestly', () => { + expect(() => buildAlphaCertificateBody('', 1818806400)).toThrow() + expect(() => buildAlphaCertificateBody('x'.repeat(32), 1818806400)).toThrow() + expect(() => buildAlphaCertificateBody('KeepKey\nVault', 1818806400)).toThrow() + }) + + it('accepts only the reviewed delegate and recomputes the EIP-712 digest', () => { + const body = bodyHex() + const messageHash = ethersUtils.keccak256(`0x${body}`).slice(2) + const result = inspectAlphaCertificateBody(body, messageHash, 1787500000) + expect(result.alias).toBe('KeepKey Alpha 716') + expect(result.chainId).toBe(1) + expect(result.delegatePublicKey).toBe(ALPHA_DELEGATE_PUBLIC_KEY) + expect(result.signingDigest).toBe(ethersUtils.keccak256(ethersUtils.concat([ + '0x1901', + `0x${CLEARSIGN_DOMAIN_SEPARATOR}`, + `0x${messageHash}`, + ])).slice(2)) + }) + + it.each([ + ['wrong capability', { flags: 0 }], + ['wrong chain', { chain: 8453 }], + ['expired', { expiry: 1787270400 }], + ['wrong delegate', { delegate: `02${'11'.repeat(32)}` }], + ])('rejects %s', (_label, overrides) => { + const body = bodyHex(overrides) + const hash = ethersUtils.keccak256(`0x${body}`).slice(2) + expect(() => inspectAlphaCertificateBody(body, hash, 1787500000)).toThrow() + }) + + it('rejects an independent hash mismatch', () => { + expect(() => inspectAlphaCertificateBody(bodyHex(), '11'.repeat(32), 1787500000)) + .toThrow('independent message hash does not match') + }) +}) diff --git a/projects/keepkey-vault/src/bun/clearsign-alpha-ceremony.ts b/projects/keepkey-vault/src/bun/clearsign-alpha-ceremony.ts new file mode 100644 index 00000000..1b38ec4b --- /dev/null +++ b/projects/keepkey-vault/src/bun/clearsign-alpha-ceremony.ts @@ -0,0 +1,167 @@ +import { utils as ethersUtils } from 'ethers' + +export const ALPHA_ROOT_PATH = [0x8000002c, 0x8000003c, 0x80000000, 0, 0] +export const ALPHA_ROOT_PUBLIC_KEY = '02de9231b2094433235532fb1932e324a2c7304195e12e610c675cccbbd606dae7' +export const ALPHA_DELEGATE_PUBLIC_KEY = '0342f5f9704494b3f9bd72295eecaf29d783d23ea02b2dc9f48abcd2e46d4850cf' +export const ALPHA_DELEGATE_FINGERPRINT = 'a9531b9d' +export const CLEARSIGN_DOMAIN_SEPARATOR = '8839401f8d0112b4348770ddace152e96fc5e5081aefeed6b5d8bef0d6ecdf66' +export const CLEARSIGN_MIN_EXPIRY = 1787270400 + +/** scope_id values this build is allowed to request/verify a certificate for. */ +export const CLEARSIGN_SCOPE_ETHEREUM = 1 +export const CLEARSIGN_SCOPE_SOLANA = 501 +const ALLOWED_SCOPES = new Set([CLEARSIGN_SCOPE_ETHEREUM, CLEARSIGN_SCOPE_SOLANA]) + +const CERT_BODY_BYTES = 75 +const CERT_SIGNATURE_BYTES = 64 +const SECP256K1_HALF_ORDER = BigInt('0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0') + +function exactHex(input: string, bytes: number, label: string): Buffer { + const value = String(input || '').replace(/^0x/i, '') + if (!/^[0-9a-fA-F]+$/.test(value) || value.length !== bytes * 2) { + throw new Error(`${label} must be exactly ${bytes} bytes of hex`) + } + return Buffer.from(value, 'hex') +} + +function decodeAlias(bytes: Buffer): string { + const firstNul = bytes.indexOf(0) + if (firstNul < 1 || firstNul > 31) throw new Error('certificate alias must be 1-31 NUL-padded ASCII bytes') + for (let i = 0; i < firstNul; i++) { + if (bytes[i] < 0x20 || bytes[i] > 0x7e) throw new Error('certificate alias contains non-printable ASCII') + } + for (let i = firstNul; i < bytes.length; i++) { + if (bytes[i] !== 0) throw new Error('certificate alias has nonzero bytes after its NUL terminator') + } + return bytes.subarray(0, firstNul).toString('ascii') +} + +function encodeAlias(alias: string): Buffer { + const value = String(alias || '') + const encoded = Buffer.from(value, 'ascii') + if (encoded.length < 1 || encoded.length > 31 || encoded.toString('ascii') !== value) { + throw new Error('certificate alias must be 1-31 printable ASCII characters') + } + for (const byte of encoded) { + if (byte < 0x20 || byte > 0x7e) throw new Error('certificate alias contains non-printable ASCII') + } + const padded = Buffer.alloc(32) + encoded.copy(padded) + return padded +} + +export interface AlphaCertificateBody { + body: Buffer + alias: string + chainId: number + notAfter: number + delegatePublicKey: string + messageHash: string + signingDigest: string +} + +/** Build the exact 75-byte body the root KeepKey reviews and signs. */ +export function buildAlphaCertificateBody( + alias: string, + notAfter: number, + scope: number = CLEARSIGN_SCOPE_ETHEREUM, +): { signedBodyHex: string; expectedMessageHashHex: string } { + if (!Number.isSafeInteger(notAfter) || notAfter < 0 || notAfter > 0xffffffff) { + throw new Error('certificate expiry must be a uint32 Unix timestamp') + } + if (!ALLOWED_SCOPES.has(scope)) throw new Error(`unsupported certificate scope ${scope}`) + const body = Buffer.alloc(CERT_BODY_BYTES) + body[0] = 1 // certificate version + body[1] = 1 // MAY_SUPPRESS_RAW and no other capabilities + body.writeUInt32BE(scope, 2) + body.writeUInt32BE(notAfter, 6) + encodeAlias(alias).copy(body, 10) + exactHex(ALPHA_DELEGATE_PUBLIC_KEY, 33, 'alpha delegate public key').copy(body, 42) + return { + signedBodyHex: body.toString('hex'), + expectedMessageHashHex: ethersUtils.keccak256(body).slice(2), + } +} + +/** Validate the one alpha certificate this Vault build is allowed to request. */ +export function inspectAlphaCertificateBody( + signedBodyHex: string, + expectedMessageHashHex: string, + nowSeconds = Math.floor(Date.now() / 1000), +): AlphaCertificateBody { + const body = exactHex(signedBodyHex, CERT_BODY_BYTES, 'signed certificate body') + if (body[0] !== 1) throw new Error('certificate version must be 1') + if (body[1] !== 1) throw new Error('alpha certificate must grant exactly MAY_SUPPRESS_RAW') + + const chainId = body.readUInt32BE(2) + if (!ALLOWED_SCOPES.has(chainId)) throw new Error(`certificate scope ${chainId} is not a reviewed alpha scope`) + const notAfter = body.readUInt32BE(6) + if (notAfter <= CLEARSIGN_MIN_EXPIRY) throw new Error('certificate expiry does not clear the 7.16 revocation floor') + if (notAfter <= nowSeconds) throw new Error('certificate is already expired') + + const alias = decodeAlias(body.subarray(10, 42)) + const delegatePublicKey = body.subarray(42, 75).toString('hex') + if (delegatePublicKey !== ALPHA_DELEGATE_PUBLIC_KEY) { + throw new Error(`certificate delegate must be the reviewed alpha signer ${ALPHA_DELEGATE_FINGERPRINT}`) + } + + const messageHash = ethersUtils.keccak256(body).slice(2) + const expectedMessageHash = exactHex(expectedMessageHashHex, 32, 'independent message hash').toString('hex') + if (messageHash !== expectedMessageHash) throw new Error('independent message hash does not match the certificate body') + const signingDigest = ethersUtils.keccak256(ethersUtils.concat([ + '0x1901', + `0x${CLEARSIGN_DOMAIN_SEPARATOR}`, + `0x${messageHash}`, + ])).slice(2) + + return { body, alias, chainId, notAfter, delegatePublicKey, messageHash, signingDigest } +} + +export function verifyAlphaRootSignature( + inspection: AlphaCertificateBody, + signatureHex: string, +): { certificateHex: string; address: string } { + const raw = exactHex(signatureHex, 65, 'device signature') + const recovery = raw[64] + if (recovery !== 27 && recovery !== 28) throw new Error('device signature recovery byte must be 27 or 28') + const s = BigInt(`0x${raw.subarray(32, 64).toString('hex')}`) + if (s > SECP256K1_HALF_ORDER) throw new Error('device returned a non-canonical high-S signature') + + const recovered = ethersUtils.recoverPublicKey(`0x${inspection.signingDigest}`, `0x${raw.toString('hex')}`) + const compressed = ethersUtils.computePublicKey(recovered, true).slice(2).toLowerCase() + if (compressed !== ALPHA_ROOT_PUBLIC_KEY) throw new Error('signature was not produced by the reviewed alpha root') + + return { + certificateHex: Buffer.concat([inspection.body, raw.subarray(0, CERT_SIGNATURE_BYTES)]).toString('hex'), + address: ethersUtils.computeAddress(`0x${ALPHA_ROOT_PUBLIC_KEY}`), + } +} + +/** Validate a complete 139-byte certificate before it is given to a signer. */ +export function inspectAlphaCertificate( + certificateHex: string, + nowSeconds = Math.floor(Date.now() / 1000), +): AlphaCertificateBody { + const certificate = exactHex(certificateHex, CERT_BODY_BYTES + CERT_SIGNATURE_BYTES, 'alpha certificate') + const body = certificate.subarray(0, CERT_BODY_BYTES) + const inspection = inspectAlphaCertificateBody( + body.toString('hex'), + ethersUtils.keccak256(body).slice(2), + nowSeconds, + ) + const compact = certificate.subarray(CERT_BODY_BYTES) + const r = `0x${compact.subarray(0, 32).toString('hex')}` + const s = `0x${compact.subarray(32, 64).toString('hex')}` + const matchesRoot = [27, 28].some((v) => { + try { + return ethersUtils.computePublicKey( + ethersUtils.recoverPublicKey(`0x${inspection.signingDigest}`, { r, s, v }), + true, + ).slice(2).toLowerCase() === ALPHA_ROOT_PUBLIC_KEY + } catch { + return false + } + }) + if (!matchesRoot) throw new Error('certificate was not signed by the reviewed alpha root') + return inspection +} diff --git a/projects/keepkey-vault/src/bun/emulator-library.test.ts b/projects/keepkey-vault/src/bun/emulator-library.test.ts new file mode 100644 index 00000000..e89e55f8 --- /dev/null +++ b/projects/keepkey-vault/src/bun/emulator-library.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { bundledEmulatorCandidates, emulatorLibFilename, resolveEmulatorLibPath } from './emulator-library' + +describe('emulator release library resolution', () => { + test('uses the correct platform filenames', () => { + expect(emulatorLibFilename('darwin')).toBe('libkkemu.dylib') + expect(emulatorLibFilename('win32')).toBe('libkkemu.dll') + }) + + test('finds a library copied to Resources/app/emulator', () => { + const candidates = bundledEmulatorCandidates('/app/Contents/Resources/app/bun', '/unused', 'darwin') + const bundled = '/app/Contents/Resources/app/emulator/libkkemu.dylib' + expect(candidates).toContain(bundled) + expect(resolveEmulatorLibPath({ + importDir: '/app/Contents/Resources/app/bun', + cwd: '/unused', + home: '/home/test', + platform: 'darwin', + exists: path => path === bundled, + })).toBe(bundled) + }) + + test('keeps a user-installed library as an explicit override', () => { + const override = '/home/test/.keepkey/emulator/libkkemu.dll' + expect(resolveEmulatorLibPath({ + importDir: 'C:/app/Resources/app/bun', + cwd: 'C:/app', + home: '/home/test', + platform: 'win32', + exists: path => path === override || path.endsWith('/emulator/libkkemu.dll'), + })).toBe(override) + }) +}) diff --git a/projects/keepkey-vault/src/bun/emulator-library.ts b/projects/keepkey-vault/src/bun/emulator-library.ts new file mode 100644 index 00000000..2e9c77a6 --- /dev/null +++ b/projects/keepkey-vault/src/bun/emulator-library.ts @@ -0,0 +1,52 @@ +import { existsSync } from 'fs' +import { homedir } from 'os' +import { join, resolve } from 'path' + +type SupportedPlatform = 'darwin' | 'win32' | 'linux' + +export function emulatorLibFilename(platform: SupportedPlatform = process.platform as SupportedPlatform): string { + if (platform === 'win32') return 'libkkemu.dll' + if (platform === 'linux') return 'libkkemu.so' + return 'libkkemu.dylib' +} + +export function userEmulatorLibPath( + platform: SupportedPlatform = process.platform as SupportedPlatform, + home = homedir(), +): string { + return join(home, '.keepkey', 'emulator', emulatorLibFilename(platform)) +} + +/** Candidate locations for Resources/app/emulator and source-tree staging. */ +export function bundledEmulatorCandidates( + importDir: string, + cwd: string, + platform: SupportedPlatform = process.platform as SupportedPlatform, +): string[] { + const filename = emulatorLibFilename(platform) + const candidates: string[] = [] + for (let depth = 0; depth <= 12; depth++) { + const parents = Array(depth).fill('..') + candidates.push(resolve(importDir, ...parents, 'emulator', filename)) + candidates.push(resolve(importDir, ...parents, 'emulator-bundle', filename)) + } + candidates.push(resolve(cwd, 'emulator-bundle', filename)) + candidates.push(resolve(cwd, 'projects', 'keepkey-vault', 'emulator-bundle', filename)) + return [...new Set(candidates)] +} + +/** User-installed libraries remain an explicit override; releases need no install. */ +export function resolveEmulatorLibPath(options: { + importDir: string + cwd?: string + home?: string + platform?: SupportedPlatform + exists?: (path: string) => boolean +}): string | null { + const platform = options.platform ?? process.platform as SupportedPlatform + const exists = options.exists ?? existsSync + const override = userEmulatorLibPath(platform, options.home ?? homedir()) + if (exists(override)) return override + return bundledEmulatorCandidates(options.importDir, options.cwd ?? process.cwd(), platform) + .find(exists) ?? null +} diff --git a/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts b/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts index f1711b82..74d9e033 100644 --- a/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts +++ b/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts @@ -37,4 +37,13 @@ describe('emulator window layout', () => { expect(metadataRule).toContain('width: min(320px, calc(100vw - 24px))') expect(buttonsRule).toContain('width: min(320px, calc(100vw - 24px))') }) + + test('does not steal a queued transport response after a confirmed operation', () => { + const gatedConfirm = html.match( + /export async function emuGatedConfirm[\s\S]*?export async function emuInteractiveConfirm/, + )?.[0] ?? '' + + expect(gatedConfirm).toContain('await saveEmulatorState()') + expect(gatedConfirm).not.toContain('flushRingBuffers') + }) }) diff --git a/projects/keepkey-vault/src/bun/emulator-window.ts b/projects/keepkey-vault/src/bun/emulator-window.ts index 605fe60a..7bee100c 100644 --- a/projects/keepkey-vault/src/bun/emulator-window.ts +++ b/projects/keepkey-vault/src/bun/emulator-window.ts @@ -573,7 +573,7 @@ export async function emuGatedConfirm( delegate: ConfirmDelegate | null, opts: { interactive: true; details: EmulatorConfirmDetails } | { interactive: false }, ): Promise { - const { saveEmulatorState, flushRingBuffers } = await import('./emulator') + const { saveEmulatorState } = await import('./emulator') const { writeDecision } = await import('./emulator-transport') let rejected = false @@ -621,7 +621,12 @@ export async function emuGatedConfirm( throw e } finally { if (delegate) delegate.onButtonRequest = prevHandler - flushRingBuffers() // drain any late output so the next op reads clean + // Do not drain the transport here. fn() owns its complete request/response + // exchange and releases hdwallet's transport lock before this finally runs. + // A queued request can therefore acquire the lock and receive its response + // in this gap; draining globally would steal that response and strand the + // queued call until its read timeout. This happened when a background + // getFeatures followed an EIP-712 sign, blocking the next sign for 240s. sendDismiss() } } diff --git a/projects/keepkey-vault/src/bun/emulator.ts b/projects/keepkey-vault/src/bun/emulator.ts index 47ae4e5d..19097d44 100644 --- a/projects/keepkey-vault/src/bun/emulator.ts +++ b/projects/keepkey-vault/src/bun/emulator.ts @@ -10,9 +10,8 @@ * ├─ emulator.ts (this) — flash lifecycle, FFI bridge * └─ libkkemu.dylib — firmware as shared library (loaded via bun:ffi) * - * 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. + * Release builds carry a verified 7.16 library at Resources/app/emulator/. + * A library dropped into ~/.keepkey/emulator remains an explicit override. */ import { dlopen, FFIType, ptr } from 'bun:ffi' import { join } from 'path' @@ -25,6 +24,7 @@ import { } from './emulator-keychain' import { startEmulatorWatchdog, stopEmulatorWatchdog } from './emulator-watchdog' import type { EmulatorStatus, EmulatorProcessState } from '../shared/types' +import { emulatorLibFilename, resolveEmulatorLibPath } from './emulator-library' const TAG = '[emulator]' const FLASH_SIZE = 1048576 // 1 MB @@ -40,9 +40,7 @@ function getEmulatorBinDir(): string { /** Platform filename for the firmware shared library the vault loads via FFI. */ export function getLibFilename(): string { - if (process.platform === 'win32') return 'libkkemu.dll' - if (process.platform === 'linux') return 'libkkemu.so' - return 'libkkemu.dylib' + return emulatorLibFilename() } /** Path to the user-installed emulator library. May not exist yet. */ @@ -50,9 +48,14 @@ export function getDylibPath(): string { return join(getEmulatorBinDir(), getLibFilename()) } -/** True when the user has installed a dylib. */ +/** True when either a user override or bundled release library is available. */ export function isDylibInstalled(): boolean { - return existsSync(getDylibPath()) + return getRuntimeDylibPath() !== null +} + +/** User override first, then the library shipped inside the release bundle. */ +export function getRuntimeDylibPath(): string | null { + return resolveEmulatorLibPath({ importDir: import.meta.dir }) } // ── FFI Handle ────────────────────────────────────────────────────────── @@ -142,11 +145,11 @@ export function initEmulator(flashName = 'default'): EmulatorStatus { // 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()) { + const dylibPath = getRuntimeDylibPath() + if (!dylibPath) { const lib = getLibFilename() const how = process.platform === 'win32' ? 'make build-emulator-windows' : 'make build-emulator' - throw new Error(`No emulator installed. Drop a ${lib} onto the window or run: ${how}`) + throw new Error(`Bundled emulator missing. Drop a ${lib} onto the window or run: ${how}`) } // 2. Decrypt flash diff --git a/projects/keepkey-vault/src/bun/evm-certified-registry.test.ts b/projects/keepkey-vault/src/bun/evm-certified-registry.test.ts new file mode 100644 index 00000000..bd2ebe5d --- /dev/null +++ b/projects/keepkey-vault/src/bun/evm-certified-registry.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { findCertifiedEvmEnvelope } from './evm-certified-registry' +import { DEFAULT_CLEARSIGN_SERVICE_URL } from './solana-certified-registry' + +const originalFetch = globalThis.fetch +const originalServiceUrl = process.env.CLEARSIGN_SERVICE_URL +const TO = '0xbf5A7F3629fB325E2a8453D595AB103465F75E62' +const DATA = `0xa2e42c65${'00'.repeat(1472)}` + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalServiceUrl === undefined) delete process.env.CLEARSIGN_SERVICE_URL + else process.env.CLEARSIGN_SERVICE_URL = originalServiceUrl +}) + +function verified(overrides: Record = {}) { + return new Response(JSON.stringify({ + classification: 'VERIFIED', + keyId: 0x80, + chainId: 1, + contract: TO, + selector: '0xa2e42c65', + method: 'Portals swap', + fingerprint: 'a9531b9d', + signedPayload: `0x03${'11'.repeat(240)}`, + ...overrides, + }), { status: 200, headers: { 'content-type': 'application/json' } }) +} + +describe('findCertifiedEvmEnvelope', () => { + test('uses the production signer after a GUI relaunch with no shell environment', async () => { + delete process.env.CLEARSIGN_SERVICE_URL + let requestedUrl = '' + globalThis.fetch = (async (input: string | URL | Request) => { + requestedUrl = String(input) + return verified() + }) as typeof fetch + + const result = await findCertifiedEvmEnvelope(1, TO, DATA) + expect(requestedUrl).toBe(`${DEFAULT_CLEARSIGN_SERVICE_URL}/v1/evm/schema`) + expect(result?.keyId).toBe(0x80) + }) + + test('sends only the privacy-preserving transaction shape', async () => { + process.env.CLEARSIGN_SERVICE_URL = 'http://127.0.0.1:1647/' + let requestBody: any + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) + return verified() + }) as typeof fetch + + const result = await findCertifiedEvmEnvelope(1, TO, DATA) + expect(requestBody).toEqual({ chainId: 1, contract: TO, selector: '0xa2e42c65', calldataLength: 1476 }) + expect(JSON.stringify(requestBody)).not.toContain(DATA.slice(10)) + expect(result?.keyId).toBe(0x80) + }) + + test('treats a catalog miss as no enhancement', async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ classification: 'OPAQUE' }), { status: 422 })) as typeof fetch + expect(await findCertifiedEvmEnvelope(1, TO, DATA)).toBeUndefined() + }) + + test('rejects wrong bindings or non-certified material before hdwallet', async () => { + globalThis.fetch = (async () => verified({ chainId: 8453 })) as typeof fetch + await expect(findCertifiedEvmEnvelope(1, TO, DATA)).rejects.toThrow(/invalid certified EVM envelope/) + globalThis.fetch = (async () => verified({ keyId: 3 })) as typeof fetch + await expect(findCertifiedEvmEnvelope(1, TO, DATA)).rejects.toThrow(/invalid certified EVM envelope/) + globalThis.fetch = (async () => verified({ signedPayload: `0x02${'11'.repeat(240)}` })) as typeof fetch + await expect(findCertifiedEvmEnvelope(1, TO, DATA)).rejects.toThrow(/invalid certified EVM envelope/) + }) +}) diff --git a/projects/keepkey-vault/src/bun/evm-certified-registry.ts b/projects/keepkey-vault/src/bun/evm-certified-registry.ts new file mode 100644 index 00000000..b6d81bfc --- /dev/null +++ b/projects/keepkey-vault/src/bun/evm-certified-registry.ts @@ -0,0 +1,80 @@ +import { DEFAULT_CLEARSIGN_SERVICE_URL } from './solana-certified-registry' + +export interface CertifiedEvmEnvelope { + method: string + signedPayload: string + keyId: number + fingerprint: string +} + +function normalizedCalldata(data: string): string | undefined { + const value = String(data || '').replace(/^0x/i, '') + if (value.length < 8 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) return undefined + return value +} + +/** + * Fetch a reviewed static/dynamic schema by transaction shape. Argument + * values never leave Vault: the service receives only chain, contract, + * selector and byte length. The response is untrusted until the KeepKey + * verifies both signatures and decodes the actual transaction itself. + */ +export async function findCertifiedEvmEnvelope( + chainId: number | undefined, + contract: string | undefined, + data: string | undefined, +): Promise { + const calldata = normalizedCalldata(String(data || '')) + if (!chainId || !contract || !calldata || !/^0x[0-9a-f]{40}$/i.test(contract)) return undefined + const selector = `0x${calldata.slice(0, 8).toLowerCase()}` + const base = String(process.env.CLEARSIGN_SERVICE_URL || DEFAULT_CLEARSIGN_SERVICE_URL) + .trim() + .replace(/\/+$/, '') + + let response: Response + try { + response = await fetch(`${base}/v1/evm/schema`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chainId, + contract, + selector, + calldataLength: calldata.length / 2, + }), + signal: AbortSignal.timeout(10_000), + }) + } catch (error: any) { + throw new Error(`ClearSign verification service is unavailable: ${error?.message || 'connection failed'}`) + } + + let result: any + try { + result = await response.json() + } catch { + throw new Error(`ClearSign verification service returned HTTP ${response.status} without valid JSON`) + } + if (!response.ok) { + if (response.status === 422) return undefined + throw new Error(`ClearSign verification service returned HTTP ${response.status}: ${result?.error || 'request failed'}`) + } + + const payload = String(result?.signedPayload || '').replace(/^0x/i, '') + if ( + result?.classification !== 'VERIFIED' || + result?.keyId !== 0x80 || + Number(result?.chainId) !== chainId || + String(result?.contract || '').toLowerCase() !== contract.toLowerCase() || + String(result?.selector || '').toLowerCase() !== selector || + payload.length <= 280 || payload.slice(0, 2).toLowerCase() !== '03' || + payload.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(payload) + ) { + throw new Error('ClearSign verification service returned an invalid certified EVM envelope') + } + return { + method: String(result.method || 'Reviewed contract call'), + signedPayload: `0x${payload}`, + keyId: result.keyId, + fingerprint: String(result.fingerprint || ''), + } +} diff --git a/projects/keepkey-vault/src/bun/evm-certified-schema.test.ts b/projects/keepkey-vault/src/bun/evm-certified-schema.test.ts new file mode 100644 index 00000000..83a64673 --- /dev/null +++ b/projects/keepkey-vault/src/bun/evm-certified-schema.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test' + +import { + buildEvmSchemaBody, + buildEvmV2SchemaBody, + CERTIFIED_EVM_CATALOG, + CERTIFIED_METADATA_KEY_ID, + findCertifiedEvmSchemaByShape, + findCertifiedEvmSchemaSpec, +} from './evm-certified-schema' + +const TO = '0x4cd00e387622c35bddb9b4c962c136462338bc31' +const DATA = + '0x49290c1c' + + '000000000000000000000000909ef6b32dfdc12ca86aa710b54c991af3c5f82e' + + '8a2c121197efc95c42f53142ab409735ee353287f877ed4d351f63094d5bfcb1' +const PORTALS = '0xbf5A7F3629fB325E2a8453D595AB103465F75E62' + +describe('7.16 certified EVM schemas', () => { + it('serializes the Relay schema with a delegate sentinel trailer', () => { + const spec = CERTIFIED_EVM_CATALOG[`1:${TO}:0x49290c1c`] + const body = buildEvmV2SchemaBody(spec) + expect(body[0]).toBe(0x02) + expect(body[body.length - 1]).toBe(CERTIFIED_METADATA_KEY_ID) + expect(body.subarray(1, 5).readUInt32BE()).toBe(1) + expect(body.includes(Buffer.from('bridgeDeposit', 'ascii'))).toBe(true) + }) + + it('matches only the complete reviewed Relay calldata shape', () => { + expect(findCertifiedEvmSchemaSpec(1, TO, DATA)?.method).toBe('bridgeDeposit') + expect(findCertifiedEvmSchemaSpec(1, TO, `${DATA}00`)).toBeUndefined() + expect(findCertifiedEvmSchemaSpec(8453, TO, DATA)).toBeUndefined() + expect(findCertifiedEvmSchemaSpec(1, TO, `0xdeadbeef${DATA.slice(10)}`)).toBeUndefined() + }) + + it('matches the privacy-preserving call shape without argument values', () => { + expect(findCertifiedEvmSchemaByShape(1, TO, '0x49290c1c', 68)?.method).toBe('bridgeDeposit') + expect(findCertifiedEvmSchemaByShape(1, TO, '0x49290c1c', 100)).toBeUndefined() + expect(findCertifiedEvmSchemaByShape(1, TO, '0xdeadbeef', 68)).toBeUndefined() + }) + + it('serializes and bounds the firmware-owned Portals dynamic decoder', () => { + const spec = findCertifiedEvmSchemaByShape(1, PORTALS, '0xa2e42c65', 1476) + expect(spec?.method).toBe('Portals swap') + expect(findCertifiedEvmSchemaByShape(1, PORTALS, '0xa2e42c65', 1477)).toBeUndefined() + expect(findCertifiedEvmSchemaByShape(1, PORTALS, '0xa2e42c65', 16_420)).toBeUndefined() + const body = buildEvmSchemaBody(spec!) + expect(body[0]).toBe(0x04) + expect(body.includes(Buffer.from('Portals swap', 'ascii'))).toBe(true) + expect(body[body.length - 1]).toBe(CERTIFIED_METADATA_KEY_ID) + }) +}) diff --git a/projects/keepkey-vault/src/bun/evm-certified-schema.ts b/projects/keepkey-vault/src/bun/evm-certified-schema.ts new file mode 100644 index 00000000..f19d6e82 --- /dev/null +++ b/projects/keepkey-vault/src/bun/evm-certified-schema.ts @@ -0,0 +1,253 @@ +import { createHash } from 'node:crypto' +import { utils as ethersUtils } from 'ethers' + +import { + ALPHA_DELEGATE_PUBLIC_KEY, + ALPHA_DELEGATE_FINGERPRINT, + CLEARSIGN_SCOPE_ETHEREUM, + inspectAlphaCertificate, +} from './clearsign-alpha-ceremony' + +export const CERTIFIED_METADATA_VERSION = 0x03 +export const CERTIFIED_METADATA_KEY_ID = 0x80 + +export const EVM_ARG_ADDRESS = 1 +export const EVM_ARG_AMOUNT = 2 +export const EVM_ARG_BYTES = 3 +export const EVM_ARG_TOKEN_AMOUNT = 5 +export const EVM_DECODER_PORTALS_NATIVE_ORDER_V1 = 1 + +export interface EvmSchemaArg { + name: string + format: number + decimals?: number + symbol?: string +} + +export interface EvmSchemaSpec { + chainId: number + contract: string + selector: string + method: string + args: EvmSchemaArg[] + /** Fixed v2 schemas account for every byte exactly. */ + expectedCalldataLength?: number + /** v4 schemas select a reviewed firmware decoder for dynamic calldata. */ + decoder?: number + minimumCalldataLength?: number + maximumCalldataLength?: number + displayFields?: string[] + protocol?: string + maintainedBy?: string + action?: string + provenance?: Record +} + +export const CERTIFIED_EVM_CATALOG: Record = { + '1:0x4cd00e387622c35bddb9b4c962c136462338bc31:0x49290c1c': { + chainId: 1, + contract: '0x4cd00e387622c35bddb9b4c962c136462338bc31', + selector: '0x49290c1c', + method: 'bridgeDeposit', + args: [ + { name: 'depositor', format: EVM_ARG_ADDRESS }, + { name: 'orderId', format: EVM_ARG_BYTES }, + ], + expectedCalldataLength: 68, + }, + '1:0xbf5a7f3629fb325e2a8453d595ab103465f75e62:0xa2e42c65': { + chainId: 1, + contract: '0xbf5A7F3629fB325E2a8453D595AB103465F75E62', + selector: '0xa2e42c65', + method: 'Portals swap', + args: [], + decoder: EVM_DECODER_PORTALS_NATIVE_ORDER_V1, + minimumCalldataLength: 452, + maximumCalldataLength: 16_388, + displayFields: ['Output token', 'Minimum output', 'Recipient', 'Native input amount'], + protocol: 'Portals', + maintainedBy: 'Portals', + action: 'Swap native ETH through the Portals router', + provenance: { + protocol: 'https://docs.portals.fi/', + verifiedContract: 'https://eth.blockscout.com/address/0xbf5A7F3629fB325E2a8453D595AB103465F75E62?tab=contract', + }, + }, +} + +function ascii(value: string, max: number, label: string): Buffer { + const bytes = Buffer.from(String(value || ''), 'ascii') + if (bytes.length < 1 || bytes.length > max || bytes.toString('ascii') !== value) { + throw new Error(`${label} must be 1-${max} printable ASCII characters`) + } + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e || byte === 0x25) { + throw new Error(`${label} contains a character the device will not render`) + } + } + return bytes +} + +function hexBytes(value: string, length: number, label: string): Buffer { + const clean = String(value || '').replace(/^0x/i, '') + if (!/^[0-9a-fA-F]+$/.test(clean) || clean.length !== length * 2) { + throw new Error(`${label} must be exactly ${length} bytes of hex`) + } + return Buffer.from(clean, 'hex') +} + +function u8(value: number): Buffer { + return Buffer.from([value & 0xff]) +} + +function be16(value: number): Buffer { + const out = Buffer.alloc(2) + out.writeUInt16BE(value) + return out +} + +function be32(value: number): Buffer { + const out = Buffer.alloc(4) + out.writeUInt32BE(value) + return out +} + +export function findCertifiedEvmSchemaSpec( + chainId: number | undefined, + contract: string | undefined, + data: string | undefined, +): EvmSchemaSpec | undefined { + if (!chainId || !contract || !data) return undefined + const calldata = data.replace(/^0x/i, '') + if (!/^[0-9a-fA-F]+$/.test(calldata) || calldata.length < 8 || calldata.length % 2 !== 0) return undefined + return findCertifiedEvmSchemaByShape( + chainId, + contract, + `0x${calldata.slice(0, 8).toLowerCase()}`, + calldata.length / 2, + ) +} + +/** Match without sending transaction arguments to a remote schema service. */ +export function findCertifiedEvmSchemaByShape( + chainId: number | undefined, + contract: string | undefined, + selector: string | undefined, + calldataLength: number | undefined, +): EvmSchemaSpec | undefined { + if (!chainId || !contract || !selector || !Number.isInteger(calldataLength)) return undefined + const normalizedSelector = selector.toLowerCase() + if (!/^0x[0-9a-f]{8}$/.test(normalizedSelector)) return undefined + const spec = CERTIFIED_EVM_CATALOG[`${chainId}:${contract.toLowerCase()}:${normalizedSelector}`] + if (!spec) return undefined + if (spec.expectedCalldataLength !== undefined) { + if (calldataLength !== spec.expectedCalldataLength) return undefined + } else { + if (!spec.decoder || spec.minimumCalldataLength === undefined || spec.maximumCalldataLength === undefined) return undefined + if (calldataLength < spec.minimumCalldataLength || calldataLength > spec.maximumCalldataLength) return undefined + if ((calldataLength - 4) % 32 !== 0) return undefined + } + return spec +} + +/** Serialize a device-decoded v2 or v4 schema. */ +export function buildEvmSchemaBody(spec: EvmSchemaSpec): Buffer { + if (!Number.isInteger(spec.chainId) || spec.chainId <= 0 || spec.chainId > 0xffffffff) { + throw new Error('schema chainId must be a nonzero uint32') + } + const method = ascii(spec.method, 64, 'method') + if (spec.decoder !== undefined) { + if (spec.decoder !== EVM_DECODER_PORTALS_NATIVE_ORDER_V1 || spec.args.length !== 0) { + throw new Error('unsupported EVM dynamic decoder') + } + if (spec.minimumCalldataLength === undefined || spec.maximumCalldataLength === undefined) { + throw new Error('dynamic schema requires calldata bounds') + } + return Buffer.concat([ + u8(0x04), + be32(spec.chainId), + hexBytes(spec.contract, 20, 'contract'), + hexBytes(spec.selector, 4, 'selector'), + be16(method.length), + method, + u8(spec.decoder), + u8(1), be32(0), u8(CERTIFIED_METADATA_KEY_ID), + ]) + } + if (spec.expectedCalldataLength !== 4 + 32 * spec.args.length) { + throw new Error('schema argument widths do not account for the complete calldata') + } + const parts: Buffer[] = [ + u8(0x02), + be32(spec.chainId), + hexBytes(spec.contract, 20, 'contract'), + hexBytes(spec.selector, 4, 'selector'), + be16(method.length), + method, + u8(spec.args.length), + ] + for (const arg of spec.args) { + const name = ascii(arg.name, 32, 'argument name') + if (![EVM_ARG_ADDRESS, EVM_ARG_AMOUNT, EVM_ARG_BYTES, EVM_ARG_TOKEN_AMOUNT].includes(arg.format)) { + throw new Error(`unsupported EVM schema format ${arg.format}`) + } + parts.push(u8(name.length), name, u8(arg.format)) + if (arg.format === EVM_ARG_TOKEN_AMOUNT) { + const symbol = ascii(arg.symbol || '', 10, 'token symbol') + if (!Number.isInteger(arg.decimals) || arg.decimals! < 0 || arg.decimals! > 36) { + throw new Error('token decimals must be 0-36') + } + parts.push(u8(arg.decimals!), u8(symbol.length), symbol) + } + } + parts.push(u8(1), be32(0), u8(CERTIFIED_METADATA_KEY_ID)) + return Buffer.concat(parts) +} + +/** Backwards-compatible name retained for existing v2 callers/tests. */ +export const buildEvmV2SchemaBody = buildEvmSchemaBody + +export function buildCertifiedEvmEnvelope( + spec: EvmSchemaSpec, + certificateHex: string, + delegatePrivateKeyHex: string, +): { signedPayload: string; keyId: number; fingerprint: string; alias: string } { + const certificate = hexBytes(certificateHex, 139, 'alpha certificate') + const certificateInfo = inspectAlphaCertificate(certificate.toString('hex')) + if (certificateInfo.chainId !== CLEARSIGN_SCOPE_ETHEREUM) { + throw new Error(`certificate is scoped to ${certificateInfo.chainId}, not Ethereum (${CLEARSIGN_SCOPE_ETHEREUM})`) + } + const privateKey = hexBytes(delegatePrivateKeyHex, 32, 'delegate private key') + const signingKey = new ethersUtils.SigningKey(`0x${privateKey.toString('hex')}`) + const publicKey = ethersUtils.computePublicKey(signingKey.publicKey, true).slice(2).toLowerCase() + if (publicKey !== ALPHA_DELEGATE_PUBLIC_KEY) { + throw new Error(`delegate private key does not match reviewed signer ${ALPHA_DELEGATE_FINGERPRINT}`) + } + + const body = buildEvmSchemaBody(spec) + const digest = createHash('sha256').update(body).digest('hex') + const signature = signingKey.signDigest(`0x${digest}`) + const compact = Buffer.concat([ + hexBytes(signature.r, 32, 'signature r'), + hexBytes(signature.s, 32, 'signature s'), + u8(27 + (signature.recoveryParam ?? 0)), + ]) + const inner = Buffer.concat([body, compact]) + const envelope = Buffer.concat([u8(CERTIFIED_METADATA_VERSION), certificate, inner]) + return { + signedPayload: `0x${envelope.toString('hex')}`, + keyId: CERTIFIED_METADATA_KEY_ID, + fingerprint: ALPHA_DELEGATE_FINGERPRINT, + alias: certificateInfo.alias, + } +} + +export function isCertifiedEvmMetadata(metadata: unknown): boolean { + const candidate = metadata as { signedPayload?: unknown; keyId?: unknown } | null + if (!candidate || candidate.keyId !== CERTIFIED_METADATA_KEY_ID) return false + const payload = candidate.signedPayload + if (payload instanceof Uint8Array) return payload.length > 140 && payload[0] === CERTIFIED_METADATA_VERSION + if (typeof payload !== 'string') return false + const clean = payload.replace(/^0x/i, '') + return clean.length > 280 && clean.slice(0, 2).toLowerCase() === '03' && /^[0-9a-fA-F]+$/.test(clean) +} diff --git a/projects/keepkey-vault/src/bun/evm-rpc.ts b/projects/keepkey-vault/src/bun/evm-rpc.ts index 12298663..90f9480f 100644 --- a/projects/keepkey-vault/src/bun/evm-rpc.ts +++ b/projects/keepkey-vault/src/bun/evm-rpc.ts @@ -119,7 +119,11 @@ export async function getErc20Decimals(rpcUrl: string, tokenContract: string): P export async function getEvmBalance(rpcUrl: string, address: string): Promise { const result = await ethRpc(rpcUrl, 'eth_getBalance', [address, 'latest']) - return BigInt(result || '0x0') + // No `|| '0x0'` fallback: a missing result means the RPC failed, not that the + // account is empty. Reporting it as zero surfaced as "Insufficient ETH: + // need 1.65, have 0" on funded accounts. Throw so callers can fall back. + if (typeof result !== 'string') throw new Error(`eth_getBalance returned no result for ${address}`) + return BigInt(result) } export async function getEvmGasPrice(rpcUrl: string): Promise { diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 8b065624..63a305b4 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -145,9 +145,10 @@ import { addSessionActivity, getSessionActivity, clearSessionActivity } from "./ import { buildTx, broadcastTx } from "./txbuilder" import { buildCosmosStakingTx, buildCosmosNameRegTx } from "./txbuilder/cosmos" import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, sendShielded, ensureFvkLoaded, displayOrchardAddressOnDevice } from "./txbuilder/zcash-shielded" -import { isSidecarReady, startSidecar, stopSidecar, wipeSidecarWalletDb, hasFvkLoaded, getCachedFvk, onScanProgress, getScanState, updateSyncedTo, beginZcashSend, endZcashSend, isZcashSendInFlight } from "./zcash-sidecar" -import { CHAINS, customChainToChainDef, isChainSupported, hiveRolePath, supportedBtcScriptTypes, btcTaprootSupported } from "../shared/chains" +import { findZcashCliBinary, isSidecarReady, startSidecar, stopSidecar, wipeSidecarWalletDb, hasFvkLoaded, getCachedFvk, onScanProgress, getScanState, updateSyncedTo, beginZcashSend, endZcashSend, isZcashSendInFlight } from "./zcash-sidecar" +import { CHAINS, customChainToChainDef, isChainSupported, hiveRolePath, btcTaprootSupported } from "../shared/chains" import { versionCompare } from "../shared/firmware-versions" +import { supportsZcashPrivacyBuild } from "./zcash-capability" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" import { utxoDiscoveryKey, unwrapUtxoDiscoveryKey } from "./btc-backend/types" @@ -161,10 +162,17 @@ import { rectifyWallet, getLedgerSummary, getLedgerJournals } from "./ledger" import { generateReport, reportToPdfBuffer, reportToCsv } from "./reports" import { startAudit, startBtcScan, getAudit, getAuditBtcRaw, getAuditEntry, dismissAudit, markAuditsStale, type AuditDeps } from "./audit-engine" import { chainSupportsDeepScan, chainSupportsLevelScan, chainLevelPath, deriveAddressParams, extractAddress, parseNativeScanResult, parseEvmScanResult, utxoAccountScriptPaths, explorerAddressUrl, pathToBip32, parseBip32Path } from "./chain-scan" +import { btcPairingEntries, utxoPairingEntries, evmPairingEntries, type UtxoXpub } from "./pairing-pubkeys" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import { assetData as discoveryAssetData } from "@pioneer-platform/pioneer-discovery" import { prioritizeExtraContracts, type PortfolioExtraContract } from "./portfolio-extra-contracts" import { buildSolanaSchema, inspectSolanaSchema } from "./clearsign-studio" +import { + buildProviderKeyFile, + deriveProviderKey, + validateProviderCeremony, + writeProviderKeyFile, +} from "../shared/clearsign-provider-key" import { EVM_RPC_URLS, getTokenMetadata, broadcastEvmTx, verifyEvmSigner } from "./evm-rpc" import type { ChainBalance, TokenBalance, CustomToken, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, EvmAddressSet, Bip85SeedMeta, StakingPosition, SwapAsset, AuditToken, DefiPosition, RecentActivity, ClearSignEvent, ClearSignSolanaSchemaArtifact } from "../shared/types" import type { VaultRPCSchema } from "../shared/rpc-schema" @@ -906,7 +914,13 @@ function loadSettings() { restApiEnabled = getSetting('rest_api_enabled') === '1' walletConnectEnabled = getSetting('walletconnect_enabled') === '1' bip85Enabled = getSetting('bip85_enabled') === '1' - zcashPrivacyEnabled = getSetting('zcash_privacy_enabled') === '1' + const storedZcashPrivacyEnabled = getSetting('zcash_privacy_enabled') === '1' + const zcashSidecarAvailable = !!findZcashCliBinary() + zcashPrivacyEnabled = storedZcashPrivacyEnabled && zcashSidecarAvailable + if (storedZcashPrivacyEnabled && !zcashSidecarAvailable) { + console.warn('[settings] Zcash privacy disabled — this build does not include a compatible zcash-cli sidecar') + setSetting('zcash_privacy_enabled', '0') + } hiveEnabled = getSetting('hive_enabled') === '1' emulatorEnabled = getSetting('emulator_enabled') === '1' preReleaseUpdates = getSetting('pre_release_updates') === '1' @@ -1964,7 +1978,7 @@ async function headlessSwapQuote(params: SwapQuoteParams): Promise { let estXpub: string | undefined let estAccountPath: number[] | undefined if (fromChain?.id === 'bitcoin') { - estXpubs = btcAccounts.isInitialized ? btcAccounts.getFundedXpubs() : [] + estXpubs = btcAccounts.isInitialized ? btcAccounts.getSpendableXpubs() : [] } else if (fromChain) { const results = await (engine.wallet as any).getPublicKeys([{ addressNList: fromChain.defaultPath.slice(0, 3), @@ -2104,7 +2118,7 @@ async function headlessExecuteSwap(params: ExecuteSwapParams, pushSubStage: (sta return undefined }, getAllBtcXpubs: () => { - if (btcAccounts.isInitialized) return btcAccounts.getFundedXpubs() + if (btcAccounts.isInitialized) return btcAccounts.getSpendableXpubs() return [] }, wrapSign: engine.isEmulator @@ -2112,6 +2126,7 @@ async function headlessExecuteSwap(params: ExecuteSwapParams, pushSubStage: (sta : (fn) => fn(), pushSubStage, isAdvancedModeEnabled: getAdvancedModeEnabled, + getFirmwareVersion: () => engine.getDeviceState().firmwareVersion, getSolanaRpcEndpoint: () => getSetting('solana_rpc_endpoint') || undefined, onClearSignEvent: (event) => recordClearSignEvent({ kind: 'transaction', @@ -2573,6 +2588,36 @@ const rpc = BrowserView.defineRPC({ throw cause } }, + clearsignDeriveProviderKey: async (params) => { + const alias = String(params.alias || '').trim() + if (!alias || alias.length > 31 || !/^[A-Za-z0-9 _-]+$/.test(alias)) { + throw new Error('Alias must be 1-31 letters, digits, spaces, hyphens, or underscores') + } + const childMnemonic = validateProviderCeremony({ + childMnemonic: params.childMnemonic, + wordCount: params.wordCount, + index: params.index, + }) + const key = deriveProviderKey(childMnemonic) + // PRIVACY: don't stamp a hidden wallet's fingerprint into a file on disk. + let deviceFingerprint: string | undefined + if (!engine.isPassphraseWallet) { + try { deviceFingerprint = await engine.getWalletFingerprint() } catch { /* no device */ } + } + const file = buildProviderKeyFile({ + key, + alias, + bip85WordCount: params.wordCount, + bip85Index: params.index, + deviceFingerprint, + createdAt: new Date().toISOString(), + }) + const slug = alias.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + const filePath = path.join(os.homedir(), 'Downloads', `keepkey-provider-${slug}-${key.fingerprint}.json`) + writeProviderKeyFile(filePath, file) + // Private key stays on disk — the renderer only ever sees the public half. + return { publicKeyHex: key.publicKeyHex, fingerprint: key.fingerprint, filePath } + }, clearsignListEvents: async (params) => { requireClearsignAdvancedMode() if (engine.isPassphraseWallet) return [] @@ -3612,6 +3657,38 @@ const rpc = BrowserView.defineRPC({ // presenting "source gone, destination missing". const directlyConfirmedAssetsByChain = new Map>() for (const destCaip of swapDestCaips) { + // Native SOL has no mint, so the SPL branch below skipped it entirely and a + // swap INTO SOL got no direct confirmation at all — it waited on Pioneer's + // indexer and kept showing the pre-swap balance across refreshes. + const nativeMatch = /^(solana:[^/]+)\/slip44:501$/i.exec(destCaip) + if (nativeMatch) { + const [, nativeNetworkId] = nativeMatch + const nativeOwner = pubkeys.find(p => p.chainId === 'solana')?.pubkey + if (!nativeOwner) continue + try { + const { getSolanaNativeBalance } = await import('./solana-token') + const direct = await getSolanaNativeBalance(nativeOwner, getSetting('solana_rpc_endpoint') || undefined) + // Pioneer lowercases Solana network ids in its responses while the vault + // derives them mixed-case, so this comparison MUST fold case or the entry + // never matches and the freshly-read balance is silently dropped. + const existing = allEntries.find((entry: any) => { + const entryCaip = String(entry?.caip || '') + if (!/\/slip44:501$/i.test(entryCaip)) return false + const entryNet = String(entry?.networkId || entryCaip.split('/')[0]) + return entryNet.toLowerCase() === nativeNetworkId.toLowerCase() + }) + const directAmount = Number.parseFloat(direct.amount) + const priceUsd = Number(existing?.priceUsd ?? 0) || 0 + if (existing) Object.assign(existing, { balance: direct.amount, decimals: direct.decimals, valueUsd: directAmount * priceUsd }) + const confirmedNative = directlyConfirmedAssetsByChain.get('solana') || new Set() + confirmedNative.add(destCaip) + directlyConfirmedAssetsByChain.set('solana', confirmedNative) + console.log(`[getBalances] Post-swap reconcile: direct SOL balance ${direct.amount}`) + } catch (e: any) { + console.warn(`[getBalances] Direct SOL balance lookup failed: ${e?.message || e}`) + } + continue + } const match = /^(solana:[^/]+)\/(?:token|spl):([1-9A-HJ-NP-Za-km-z]{32,44})$/.exec(destCaip) if (!match) continue const [, networkId, mint] = match @@ -5834,39 +5911,38 @@ const rpc = BrowserView.defineRPC({ const pubkeys: any[] = [] - // ── BTC: every device-supported account type ── - const btcScripts = (await supportedBtcScriptTypes(wallet)).map(s => ({ - ...s, type: s.xpubPrefix, note: `Bitcoin ${s.label}`, - })) + // The managers below hold the wallet's KNOWN accounts (not just account + // 0), so the paired phone sees the same portfolio the desktop does + // (#406). Reconcile against the live seed first — a manager left over + // from another passphrase session must never be exported to the relay. + const { truth: pairingSeed } = await ensureManagersForSeed('generateMobilePairing') + + // FAIL CLOSED. reconcileSeedManagers() is a no-op on a null identity + // (`if (!truth) return false`) — it cannot tell a stale manager from a + // fresh one without something to compare against. Every other caller + // only reads balances with that ambiguity; this one UPLOADS to the + // relay, so an inconclusive check here would publish the previous + // wallet's xpubs and addresses after a passphrase or seed change that + // happened to coincide with a USB hiccup. Unverifiable seed → no + // export. The user replugs and pairs again. + if (!pairingSeed) { + throw new Error('Could not verify which wallet is connected — refusing to build a pairing payload. Reconnect your KeepKey and try again.') + } + + // ── BTC: every known account × device-supported script type ── const btcChain = builtinChains.find(c => c.id === 'bitcoin') const btcNetwork = btcChain?.networkId || 'bip122:000000000019d6689c085ae165831e93' - for (const s of btcScripts) { - try { - const addressNList = [s.purpose + 0x80000000, 0x80000000, 0x80000000] - const addressNListMaster = [...addressNList, 0, 0] - const result = await wallet.getPublicKeys([{ - addressNList, coin: 'Bitcoin', scriptType: s.scriptType, curve: 'secp256k1', - }]) - const xpub = result?.[0]?.xpub - if (xpub && typeof xpub === 'string') { - pubkeys.push({ - type: s.type, pubkey: xpub, master: xpub, - address: xpub, // SDK expects address field - path: pathToString(addressNList), - pathMaster: pathToString(addressNListMaster), - scriptType: s.scriptType, - available_scripts_types: [...btcScripts.map(x => x.scriptType), 'p2sh'], - note: s.note, context, - networks: [btcNetwork], - addressNList, addressNListMaster, - }) - } - } catch (e: any) { console.warn(`[mobilePairing] BTC ${s.scriptType} failed:`, e.message) } - } + try { + if (!btcAccounts.isInitialized) await btcAccounts.initialize(wallet) + const btcMeta = btcAccounts.getAllXpubMeta() + if (btcMeta.length === 0) console.warn('[mobilePairing] BTC: no xpubs from account manager') + pubkeys.push(...btcPairingEntries(btcMeta, btcNetwork, context)) + } catch (e: any) { console.warn('[mobilePairing] BTC xpubs failed:', e.message) } - // ── Non-BTC UTXO chains: batch xpub derivation ── + // ── Non-BTC UTXO chains: account 0 (batch) + tracked accounts > 0 ── const utxoChains = builtinChains.filter(c => c.chainFamily === 'utxo' && c.id !== 'bitcoin') if (utxoChains.length > 0) { + const utxoXpubs: UtxoXpub[] = [] try { const xpubResults = await wallet.getPublicKeys(utxoChains.map(c => ({ addressNList: accountPath(c.defaultPath), coin: c.coin, @@ -5876,46 +5952,42 @@ const rpc = BrowserView.defineRPC({ const xpub = xpubResults?.[i]?.xpub if (xpub && typeof xpub === 'string') { const chain = utxoChains[i] - const addressNList = accountPath(chain.defaultPath) - const addressNListMaster = [...addressNList, 0, 0] - pubkeys.push({ - type: 'xpub', pubkey: xpub, master: xpub, - address: xpub, - path: pathToString(addressNList), - pathMaster: pathToString(addressNListMaster), - scriptType: chain.scriptType, - available_scripts_types: [chain.scriptType || 'p2pkh'], - note: `${chain.symbol} Default path`, context, - networks: [chain.networkId], - addressNList, addressNListMaster, - }) + utxoXpubs.push({ chainId: chain.id, xpub, scriptType: chain.scriptType, path: accountPath(chain.defaultPath) }) } } } catch (e: any) { console.warn('[mobilePairing] UTXO xpub batch failed:', e.message) } + + // Accounts beyond 0 persisted by the audit "track" action + // (addUtxoAccount). Device-scoped and never written for passphrase + // wallets, so reading them here is hidden-safe — mirrors getBalances. + const utxoDevId = engine.getDeviceState().deviceId + if (utxoDevId && !engine.isPassphraseWallet) { + const utxoIds = new Set(utxoChains.map(c => c.id)) + for (const pk of getCachedPubkeys(utxoDevId)) { + if (!utxoIds.has(pk.chainId) || !pk.xpub) continue + const path = parseBip32Path(pk.path) + if (!path || path.length < 3) continue // bitcoin rows key the xpub in `path` + utxoXpubs.push({ chainId: pk.chainId, xpub: pk.xpub, scriptType: pk.scriptType, path }) + } + } + pubkeys.push(...utxoPairingEntries(utxoXpubs, utxoChains, context)) } - // ── EVM chains: derive ONCE, emit with all EVM networkIds + wildcard ── + // ── EVM chains: every tracked address index, all EVM networkIds + wildcard ── const evmChains = builtinChains.filter(c => c.chainFamily === 'evm') if (evmChains.length > 0) { try { - const addressNList = [0x8000002C, 0x8000003C, 0x80000000] - const addressNListMaster = [0x8000002C, 0x8000003C, 0x80000000, 0, 0] - const result = await wallet.ethGetAddress({ addressNList: addressNListMaster, showDisplay: false, coin: 'Ethereum' }) - const address = typeof result === 'string' ? result : result?.address - if (address && typeof address === 'string') { - const evmNetworks = [...evmChains.map(c => c.networkId), 'eip155:*'] - pubkeys.push({ - type: 'address', pubkey: address, master: address, address, - path: pathToString(addressNList), - pathMaster: pathToString(addressNListMaster), - note: 'ETH primary (default)', context, - networks: evmNetworks, - addressNList, addressNListMaster, - }) - } + if (!evmAddresses.isInitialized) await evmAddresses.initialize(wallet) + const tracked = evmAddresses.toAddressSet().addresses + if (tracked.length === 0) console.warn('[mobilePairing] EVM: no addresses from index manager') + const evmNetworks = [...evmChains.map(c => c.networkId), 'eip155:*'] + pubkeys.push(...evmPairingEntries(tracked, evmNetworks, context)) } catch (e: any) { console.warn('[mobilePairing] EVM address failed:', e.message) } } + // Managers now reflect the connected seed — arm the staleness stamp. + stampManagers(pairingSeed) + // ── Non-EVM, non-UTXO chains: individual address derivation ── const otherChains = builtinChains.filter(c => c.chainFamily !== 'utxo' && c.chainFamily !== 'evm' && c.chainFamily !== 'zcash-shielded' @@ -6703,7 +6775,7 @@ const rpc = BrowserView.defineRPC({ return undefined }, getAllBtcXpubs: () => { - if (btcAccounts.isInitialized) return btcAccounts.getFundedXpubs() + if (btcAccounts.isInitialized) return btcAccounts.getSpendableXpubs() return [] }, wrapSign: (fn) => fn(), // unused in preview @@ -8334,16 +8406,21 @@ engine.on('state-change', (state) => { setSetting('bip85_enabled', '0') console.log(`[settings] BIP-85 auto-disabled — firmware ${fw || 'unknown'} < 7.16.0`) } - // Zcash + Hive are capabilities, not user toggles: ON whenever the - // connected device runs firmware >= 7.15.0, OFF otherwise. The setting - // row is kept as a mirror of the derived value so the getSetting() gates - // in rest-api.ts keep reading the same answer from one source. + // Zcash + Hive are capabilities, not user toggles. Both require firmware + // >= 7.15.0; Zcash privacy additionally requires the native sidecar to be + // present in this app build. The setting row mirrors the derived value so + // the getSetting() gates in rest-api.ts read the same answer. const has715 = !!fw && versionCompare(fw, '7.15.0') >= 0 - if (zcashPrivacyEnabled !== has715) { - zcashPrivacyEnabled = has715 - setSetting('zcash_privacy_enabled', has715 ? '1' : '0') - console.log(`[settings] Zcash privacy auto-${has715 ? 'enabled' : 'disabled'} — firmware ${fw || 'unknown'}`) - if (!has715) stopSidecar() + const zcashSidecarBinary = findZcashCliBinary() + const hasZcashPrivacy = supportsZcashPrivacyBuild(fw, zcashSidecarBinary) + if (zcashPrivacyEnabled !== hasZcashPrivacy) { + zcashPrivacyEnabled = hasZcashPrivacy + setSetting('zcash_privacy_enabled', hasZcashPrivacy ? '1' : '0') + const reason = !has715 + ? `firmware ${fw || 'unknown'}` + : 'compatible zcash-cli sidecar missing from this build' + console.log(`[settings] Zcash privacy auto-${hasZcashPrivacy ? 'enabled' : 'disabled'} — ${reason}`) + if (!hasZcashPrivacy) stopSidecar() else if (!isSidecarReady()) { console.log('[zcash] Starting sidecar on firmware capability detect...') startSidecar().catch((e: any) => console.error('[zcash] Sidecar failed to start:', e.message)) diff --git a/projects/keepkey-vault/src/bun/pairing-pubkeys.ts b/projects/keepkey-vault/src/bun/pairing-pubkeys.ts new file mode 100644 index 00000000..9befbb30 --- /dev/null +++ b/projects/keepkey-vault/src/bun/pairing-pubkeys.ts @@ -0,0 +1,161 @@ +/** + * Mobile-pairing payload assembly (pure — no device, db or network). + * + * The pairing relay payload used to be re-derived from account-0 defaults, so a + * wallet with funds in BTC account 1 or ETH account 2 paired a phone that + * silently under-reported the portfolio (keepkey/keepkey-vault#406). The vault + * already knows those accounts: BtcAccountManager, EvmAddressManager and the + * device-scoped cached_pubkeys rows written by addUtxoAccount. This module turns + * that known set into relay entries; index.ts does the I/O and passes it in. + */ +import { btcScriptTypeConfig, evmAddressPath } from '../shared/chains' +import type { BtcScriptType } from '../shared/types' +import { pathToBip32 } from './chain-scan' + +export interface PairingEntry { + type: string + pubkey: string + master: string + address: string + path: string + pathMaster: string + scriptType?: string + available_scripts_types?: string[] + note: string + context: string + networks: string[] + addressNList: number[] + addressNListMaster: number[] +} + +/** One xpub from BtcAccountManager.getAllXpubMeta(). */ +export interface BtcXpubMeta { + xpub: string + scriptType: BtcScriptType + accountIndex: number + path: number[] +} + +/** One account-level xpub for a non-BTC UTXO chain (derived or cached). */ +export interface UtxoXpub { + chainId: string + xpub: string + scriptType?: string + path: number[] +} + +export interface UtxoChainInfo { + id: string + symbol: string + networkId: string + scriptType?: string +} + +/** One tracked EVM address from EvmAddressManager.toAddressSet(). */ +export interface EvmAddressMeta { + address: string + addressIndex: number +} + +const masterOf = (accountPath: number[]) => [...accountPath, 0, 0] + +/** BTC: one entry per (account, script type) the manager knows about. */ +export function btcPairingEntries( + xpubs: BtcXpubMeta[], + networkId: string, + context: string, +): PairingEntry[] { + // The device-supported script set is whatever the manager derived. + const available = [...new Set(xpubs.map(x => x.scriptType as string)), 'p2sh'] + const out: PairingEntry[] = [] + for (const x of xpubs) { + if (!x.xpub || x.path.length < 3) continue + const addressNList = x.path.slice(0, 3) + const addressNListMaster = masterOf(addressNList) + const cfg = btcScriptTypeConfig(x.scriptType) + const label = cfg?.label || x.scriptType + out.push({ + type: cfg?.xpubPrefix || 'xpub', + pubkey: x.xpub, + master: x.xpub, + address: x.xpub, // SDK expects address field + path: pathToBip32(addressNList), + pathMaster: pathToBip32(addressNListMaster), + scriptType: x.scriptType, + available_scripts_types: available, + note: x.accountIndex === 0 ? `Bitcoin ${label}` : `Bitcoin ${label} account ${x.accountIndex}`, + context, + networks: [networkId], + addressNList, + addressNListMaster, + }) + } + return out +} + +/** Non-BTC UTXO: one entry per known account xpub, deduped by xpub. */ +export function utxoPairingEntries( + xpubs: UtxoXpub[], + chains: UtxoChainInfo[], + context: string, +): PairingEntry[] { + const byId = new Map(chains.map(c => [c.id, c])) + const seen = new Set() + const out: PairingEntry[] = [] + for (const x of xpubs) { + const chain = byId.get(x.chainId) + if (!chain || !x.xpub || seen.has(x.xpub) || x.path.length < 3) continue + seen.add(x.xpub) + const addressNList = x.path.slice(0, 3) + const addressNListMaster = masterOf(addressNList) + // Account index is the hardened element [2] — 0x80000000 for account 0. + const accountIndex = addressNList[2] - 0x80000000 + const scriptType = x.scriptType || chain.scriptType + out.push({ + type: 'xpub', + pubkey: x.xpub, + master: x.xpub, + address: x.xpub, + path: pathToBip32(addressNList), + pathMaster: pathToBip32(addressNListMaster), + scriptType, + available_scripts_types: [scriptType || 'p2pkh'], + note: accountIndex === 0 ? `${chain.symbol} Default path` : `${chain.symbol} account ${accountIndex}`, + context, + networks: [chain.networkId], + addressNList, + addressNListMaster, + }) + } + return out +} + +/** EVM: one entry per tracked address index — every EVM chain shares the key. */ +export function evmPairingEntries( + addresses: EvmAddressMeta[], + networks: string[], + context: string, +): PairingEntry[] { + const out: PairingEntry[] = [] + const seen = new Set() + for (const a of addresses) { + if (!a.address || seen.has(a.address.toLowerCase())) continue + seen.add(a.address.toLowerCase()) + const addressNListMaster = evmAddressPath(a.addressIndex) + const addressNList = addressNListMaster.slice(0, 3) + out.push({ + type: 'address', + pubkey: a.address, + master: a.address, + address: a.address, + path: pathToBip32(addressNList), + pathMaster: pathToBip32(addressNListMaster), + note: a.addressIndex === 0 ? 'ETH primary (default)' : `ETH account ${a.addressIndex}`, + context, + networks, + addressNList, + addressNListMaster, + }) + } + return out +} diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 34e9a250..731936a5 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -1777,7 +1777,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } signingInfo.requiresBlindSigningConsent = requiresSolanaBlindSigningConsent( signingInfo.solanaDecoded, - preview.swapMetadata !== undefined || preview.schema !== undefined, + preview.lutProof !== undefined || preview.schema !== undefined, ) if (signingInfo.requiresBlindSigningConsent) { signingInfo.needsBlindSigning = true @@ -2767,7 +2767,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // Both legacy and v0 messages use SolanaSignTx. The helper removes // the signature wrapper, forwards the transaction-bound KKSOLSW1 - // descriptor unchanged, or adds a one-shot opaque fallback only when + // proof unchanged, or adds a one-shot opaque fallback only when // the Vault UI returned explicit consent, then splices // the returned signature back into the original wire transaction. const clearSignPayload = body.schema?.payload ? String(body.schema.payload) : undefined @@ -2782,7 +2782,8 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 { addressNList, rawTx: body.raw_tx, - swapMetadata: body.swapMetadata, + lutProof: body.lutProof, + certificate: body.certificate, // Reusable KKSOLSC1 instruction schema — signed once per // program+instruction, so the device can decode this call // without a per-transaction attestation. diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index af26ac3c..94e4db18 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -165,13 +165,13 @@ export const XrpSignRequest = z.object({ }).strip() /** POST /solana/sign-transaction — sign a raw Solana transaction */ -export const SolanaSwapMetadata = z.object({ - /** Base64-encoded canonical KKSOLSW1 descriptor. */ - payload: z.string().min(1), - /** Base64-encoded 64-byte compact secp256k1 signature over SHA256(payload). */ +export const SolanaLutProof = z.object({ + /** Base64-encoded 32-byte keys in canonical writable-then-readonly order. */ + accounts: z.array(z.string().min(1)).min(1).max(8), + /** Base64-encoded 64-byte signature over the transaction-bound LUT preimage. */ signature: z.string().min(1), - /** Device ClearSign signer slot (0 = built-in, 1..3 = user-loaded). */ - signerKeyId: z.number().int().min(0).max(3), + /** Runtime slot 0-3, or the root-certified delegate sentinel. */ + signerKeyId: z.union([z.number().int().min(0).max(3), z.literal(0x80)]), }).strict() /** @@ -185,7 +185,7 @@ export const SolanaInstructionSchema = z.object({ /** Base64-encoded 64-byte compact secp256k1 signature over SHA256(payload). */ signature: z.string().min(1), /** Device ClearSign signer slot (0 = built-in, 1..3 = user-loaded). */ - signerKeyId: z.number().int().min(0).max(3), + signerKeyId: z.union([z.number().int().min(0).max(3), z.literal(0x80)]), }).strict() /** x402 v2 SVM exact PaymentRequirements needed for device-verifiable payTo. */ @@ -208,10 +208,12 @@ export const SolanaSignRequest = z.object({ address_n: z.array(z.number().int()).optional(), addressNList: z.array(z.number().int()).optional(), raw_tx: z.string().min(1), - /** Transaction-bound ClearSign metadata. Partial descriptors are rejected. */ - swapMetadata: SolanaSwapMetadata.optional(), + /** Transaction-bound LUT account proof. Omitted for self-contained messages. */ + lutProof: SolanaLutProof.optional(), /** Reusable, signer-attested instruction schema. Partial schemas rejected. */ schema: SolanaInstructionSchema.optional(), + /** 139-byte KeepKey root certificate (hex or base64). */ + certificate: z.string().min(1).optional(), /** * Optional x402 PaymentRequirements. Vault cross-checks these fields against * the signed zero-LUT v0 bytes before forwarding device display metadata. @@ -219,7 +221,22 @@ export const SolanaSignRequest = z.object({ x402: SolanaX402Requirements.optional(), // One-shot opaque-signing consent is intentionally not part of the public // REST contract. Unknown fields are stripped; the Vault UI grants consent. -}).strip() +}).strip().refine( + (v) => { + const signerIds = [v.lutProof?.signerKeyId, v.schema?.signerKeyId] + .filter((id): id is number => id !== undefined) + const certified = signerIds.some(id => id === 0x80) + const runtime = signerIds.some(id => id !== 0x80) + return !runtime || !certified + }, + { message: 'runtime and root-certified Solana signer IDs cannot be mixed' }, +).refine( + (v) => { + const certified = v.lutProof?.signerKeyId === 0x80 || v.schema?.signerKeyId === 0x80 + return certified === (v.certificate !== undefined) + }, + { message: 'certificate is required exactly when lutProof or schema uses signerKeyId 0x80' }, +) /** POST /tron/sign-transaction — sign a raw Tron transaction */ export const TronSignRequest = z.object({ diff --git a/projects/keepkey-vault/src/bun/solana-certified-lut.test.ts b/projects/keepkey-vault/src/bun/solana-certified-lut.test.ts new file mode 100644 index 00000000..6903d2dd --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-lut.test.ts @@ -0,0 +1,43 @@ +import { describe, test, expect } from 'bun:test' +import { randomBytes } from 'node:crypto' + +import { + buildLutAttestationPreimage, + signCertifiedSolanaLutAttestation, + SOL_MAX_LUT_ACCOUNTS, +} from './solana-certified-lut' + +// Real Solana scope-501 certificate issued 2026-08-24 by the master root +// signer (docs/certs/solana-scope-501-certificate.json). Public data only — +// contains no private key material. +const SOLANA_CERT_HEX = '0101000001f56c68c8804b6565704b6579205661756c74000000000000000000000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea02b2dc9f48abcd2e46d4850cfa2753fac6068a45747a32a4a39f249af72b55370f3491913b7fb9a80207d619b3b4fca6750fc1fdc790da5562b42a351e12cde3c0f084056a24ca8d1bf2c36b5' + +describe('buildLutAttestationPreimage', () => { + test('matches firmware layout: 25-byte tag + 32-byte hash + LE32 count + accounts', () => { + const hash = randomBytes(32) + const accounts = [randomBytes(32), randomBytes(32)] + const preimage = buildLutAttestationPreimage(hash, accounts) + expect(preimage.length).toBe(25 + 32 + 4 + 64) + expect(preimage.subarray(0, 25).toString('ascii')).toBe('KeepKeySolanaTxAccounts/1') + expect(preimage.subarray(25, 57)).toEqual(hash) + expect(preimage.readUInt32LE(57)).toBe(2) + expect(preimage.subarray(61, 93)).toEqual(accounts[0]) + expect(preimage.subarray(93, 125)).toEqual(accounts[1]) + }) + + test('rejects zero, over-cap, and malformed accounts', () => { + const hash = randomBytes(32) + expect(() => buildLutAttestationPreimage(hash, [])).toThrow() + expect(() => buildLutAttestationPreimage(hash, Array.from({ length: SOL_MAX_LUT_ACCOUNTS + 1 }, () => randomBytes(32)))).toThrow() + expect(() => buildLutAttestationPreimage(hash, [randomBytes(31)])).toThrow() + expect(() => buildLutAttestationPreimage(randomBytes(31), [randomBytes(32)])).toThrow() + }) +}) + +describe('signCertifiedSolanaLutAttestation', () => { + test('accepts the real Solana-scoped certificate but rejects a mismatched private key', () => { + const wrongKey = randomBytes(32).toString('hex') + expect(() => signCertifiedSolanaLutAttestation(SOLANA_CERT_HEX, wrongKey, randomBytes(32), [randomBytes(32)])) + .toThrow(/does not match reviewed signer/) + }) +}) diff --git a/projects/keepkey-vault/src/bun/solana-certified-lut.ts b/projects/keepkey-vault/src/bun/solana-certified-lut.ts new file mode 100644 index 00000000..127917d2 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-lut.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import { utils as ethersUtils } from 'ethers' + +import { + ALPHA_DELEGATE_PUBLIC_KEY, + ALPHA_DELEGATE_FINGERPRINT, + CLEARSIGN_SCOPE_SOLANA, + inspectAlphaCertificate, +} from './clearsign-alpha-ceremony' + +/** + * Certified Solana LUT account attestation (KKSOLSW1 certified path). + * + * Preimage layout (must match lib/firmware/solana.c solana_lut_accounts_preimage + * and solana_lut_accounts_certified exactly): + * "KeepKeySolanaTxAccounts/1" (25 bytes, no NUL) || message_hash(32) + * || count(uint32 LE) || account[0..count-1] (32 bytes each) + * + * Firmware verifies SHA256(preimage) against the certificate's delegate with a + * plain 64-byte compact secp256k1 signature (r||s, no recovery byte). + */ + +const LUT_TAG = Buffer.from('KeepKeySolanaTxAccounts/1', 'ascii') +export const SOL_PUBKEY_SIZE = 32 +export const SOL_MAX_LUT_ACCOUNTS = 8 + +function hexBytes(value: string, length: number, label: string): Buffer { + const clean = String(value || '').replace(/^0x/i, '') + if (!/^[0-9a-fA-F]+$/.test(clean) || clean.length !== length * 2) { + throw new Error(`${label} must be exactly ${length} bytes of hex`) + } + return Buffer.from(clean, 'hex') +} + +/** Build the exact preimage bytes the device hashes and verifies. */ +export function buildLutAttestationPreimage(messageHash: Buffer, accounts: Buffer[]): Buffer { + if (messageHash.length !== 32) throw new Error('message hash must be exactly 32 bytes') + if (accounts.length === 0 || accounts.length > SOL_MAX_LUT_ACCOUNTS) { + throw new Error(`lut account count must be 1-${SOL_MAX_LUT_ACCOUNTS}`) + } + for (const account of accounts) { + if (account.length !== SOL_PUBKEY_SIZE) throw new Error('every lut account must be exactly 32 bytes') + } + const count = Buffer.alloc(4) + count.writeUInt32LE(accounts.length) + return Buffer.concat([LUT_TAG, messageHash, count, ...accounts]) +} + +export interface CertifiedLutAttestation { + lutSignature: string // 0x-prefixed 64-byte compact secp256k1 signature + certificateHex: string + keyId: number // CERTIFIED_METADATA_KEY_ID sentinel (0x80) + fingerprint: string + alias: string +} + +/** Sign the canonical LUT account list for one exact Solana message, using the + * Solana-scoped (501) delegate certificate and its private key. The private + * key never leaves this process. */ +export function signCertifiedSolanaLutAttestation( + certificateHex: string, + delegatePrivateKeyHex: string, + messageHash: Buffer, + accounts: Buffer[], +): CertifiedLutAttestation { + const certificate = hexBytes(certificateHex, 139, 'solana certificate') + const certInfo = inspectAlphaCertificate(certificate.toString('hex')) + if (certInfo.chainId !== CLEARSIGN_SCOPE_SOLANA) { + throw new Error(`certificate is scoped to ${certInfo.chainId}, not Solana (${CLEARSIGN_SCOPE_SOLANA})`) + } + const privateKey = hexBytes(delegatePrivateKeyHex, 32, 'delegate private key') + const signingKey = new ethersUtils.SigningKey(`0x${privateKey.toString('hex')}`) + const publicKey = ethersUtils.computePublicKey(signingKey.publicKey, true).slice(2).toLowerCase() + if (publicKey !== ALPHA_DELEGATE_PUBLIC_KEY) { + throw new Error(`delegate private key does not match reviewed signer ${ALPHA_DELEGATE_FINGERPRINT}`) + } + + const preimage = buildLutAttestationPreimage(messageHash, accounts) + const digest = createHash('sha256').update(preimage).digest() + const signature = signingKey.signDigest(`0x${digest.toString('hex')}`) + const compact = Buffer.concat([ + hexBytes(signature.r, 32, 'signature r'), + hexBytes(signature.s, 32, 'signature s'), + ]) + return { + lutSignature: `0x${compact.toString('hex')}`, + certificateHex: certificate.toString('hex'), + keyId: 0x80, + fingerprint: ALPHA_DELEGATE_FINGERPRINT, + alias: certInfo.alias, + } +} + +/** Independent verification mirroring clearsign_root_verify_delegate_attestation, + * for tests and pre-flight checks before a signature is sent to the device. */ +export function verifyCertifiedSolanaLutAttestation( + certificateHex: string, + messageHash: Buffer, + accounts: Buffer[], + lutSignatureHex: string, +): boolean { + const certInfo = inspectAlphaCertificate(hexBytes(certificateHex, 139, 'solana certificate').toString('hex')) + if (certInfo.chainId !== CLEARSIGN_SCOPE_SOLANA) return false + const preimage = buildLutAttestationPreimage(messageHash, accounts) + const digest = createHash('sha256').update(preimage).digest('hex') + const sig = hexBytes(lutSignatureHex, 64, 'lut signature') + const r = `0x${sig.subarray(0, 32).toString('hex')}` + const s = `0x${sig.subarray(32, 64).toString('hex')}` + return [27, 28].some((v) => { + try { + const recovered = ethersUtils.recoverPublicKey(`0x${digest}`, { r, s, v }) + return ethersUtils.computePublicKey(recovered, true).slice(2).toLowerCase() === certInfo.delegatePublicKey + } catch { + return false + } + }) +} diff --git a/projects/keepkey-vault/src/bun/solana-certified-policy.test.ts b/projects/keepkey-vault/src/bun/solana-certified-policy.test.ts new file mode 100644 index 00000000..c5d07895 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-policy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' + +import { + CERTIFIED_CLEARSIGN_MIN_FW, + hasCompleteCertifiedSolanaEnvelope, + supportsCertifiedClearSign, +} from './solana-certified-policy' + +describe('supportsCertifiedClearSign', () => { + test('defaults on at 7.16 and remains off for older or unknown firmware', () => { + expect(CERTIFIED_CLEARSIGN_MIN_FW).toBe('7.16.0') + expect(supportsCertifiedClearSign('7.16.0')).toBe(true) + expect(supportsCertifiedClearSign('7.16.1')).toBe(true) + expect(supportsCertifiedClearSign('7.15.9')).toBe(false) + expect(supportsCertifiedClearSign(undefined)).toBe(false) + }) +}) + +describe('hasCompleteCertifiedSolanaEnvelope', () => { + const schema = { payload: 'aa', signature: 'bb', signerKeyId: 0x80 } + const certificate = 'cc' + + test('recognizes the self-contained schema + certificate shape', () => { + expect(hasCompleteCertifiedSolanaEnvelope({ schema, certificate })).toBe(true) + }) + + test('recognizes ALT-backed proof only when its delegate id is certified', () => { + expect(hasCompleteCertifiedSolanaEnvelope({ + schema, + certificate, + lutProof: { accounts: ['account'], signature: 'dd', signerKeyId: 0x80 }, + })).toBe(true) + expect(hasCompleteCertifiedSolanaEnvelope({ + schema, + certificate, + lutProof: { accounts: ['account'], signature: 'dd', signerKeyId: 3 }, + })).toBe(false) + }) + + test('does not let partial material bypass explicit blind-sign consent', () => { + expect(hasCompleteCertifiedSolanaEnvelope({ schema })).toBe(false) + expect(hasCompleteCertifiedSolanaEnvelope({ certificate })).toBe(false) + expect(hasCompleteCertifiedSolanaEnvelope({ + schema, + certificate, + lutProof: { accounts: ['account'], signerKeyId: 0x80 }, + })).toBe(false) + expect(hasCompleteCertifiedSolanaEnvelope({ + schema, + certificate, + lutProof: { accounts: [], signature: 'dd', signerKeyId: 0x80 }, + })).toBe(false) + expect(hasCompleteCertifiedSolanaEnvelope({ + schema: { ...schema, signerKeyId: 3 }, + certificate, + })).toBe(false) + }) +}) diff --git a/projects/keepkey-vault/src/bun/solana-certified-policy.ts b/projects/keepkey-vault/src/bun/solana-certified-policy.ts new file mode 100644 index 00000000..9d815133 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-policy.ts @@ -0,0 +1,32 @@ +import { versionCompare } from '../shared/firmware-versions' + +export const CERTIFIED_CLEARSIGN_MIN_FW = '7.16.0' + +/** Certified signer id 0x80 did not exist before 7.16. Unknown versions are + * treated as unsupported so Vault never sends an older device new authority + * material and then mistakes its refusal for user cancellation. */ +export function supportsCertifiedClearSign(firmwareVersion?: string): boolean { + return !!firmwareVersion && versionCompare(firmwareVersion, CERTIFIED_CLEARSIGN_MIN_FW) >= 0 +} + +/** + * Host routing predicate only. Cryptographic and transaction-shape validation + * remains the device's job; this merely prevents Vault from demanding blind + * signing when it has the complete root-certified schema envelope. + */ +export function hasCompleteCertifiedSolanaEnvelope(value: any): boolean { + if ( + !value?.schema?.payload || + !value.schema.signature || + value.schema.signerKeyId !== 0x80 || + !value.certificate + ) { + return false + } + return value.lutProof === undefined || ( + Array.isArray(value.lutProof.accounts) && + value.lutProof.accounts.length > 0 && + !!value.lutProof.signature && + value.lutProof.signerKeyId === 0x80 + ) +} diff --git a/projects/keepkey-vault/src/bun/solana-certified-registry.test.ts b/projects/keepkey-vault/src/bun/solana-certified-registry.test.ts new file mode 100644 index 00000000..ba107b27 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-registry.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { + DEFAULT_CLEARSIGN_SERVICE_URL, + findCertifiedSolanaProof, +} from './solana-certified-registry' + +const originalFetch = globalThis.fetch +const originalServiceUrl = process.env.CLEARSIGN_SERVICE_URL + +function verifiedResponse(lutProof?: unknown): Response { + return new Response(JSON.stringify({ + classification: 'VERIFIED', + ...(lutProof === undefined ? {} : { lutProof }), + schema: { + payload: '0x4b4b534f4c534331', + signature: `0x${'22'.repeat(64)}`, + signerKeyId: 0x80, + }, + certificate: `0x${'33'.repeat(139)}`, + }), { status: 200, headers: { 'content-type': 'application/json' } }) +} + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalServiceUrl === undefined) delete process.env.CLEARSIGN_SERVICE_URL + else process.env.CLEARSIGN_SERVICE_URL = originalServiceUrl +}) + +describe('findCertifiedSolanaProof', () => { + test('uses the signer by default without an enable flag', async () => { + delete process.env.CLEARSIGN_SERVICE_URL + let requestedUrl = '' + globalThis.fetch = (async (input: string | URL | Request) => { + requestedUrl = String(input) + return verifiedResponse() + }) as typeof fetch + + const result = await findCertifiedSolanaProof('unsigned-fixture', 'relayDepositNative') + expect(requestedUrl).toBe(`${DEFAULT_CLEARSIGN_SERVICE_URL}/v1/solana/certify`) + expect(result?.schema.signerKeyId).toBe(0x80) + }) + + test('accepts a schema-only certified response for a self-contained transaction', async () => { + process.env.CLEARSIGN_SERVICE_URL = 'http://127.0.0.1:1647/' + globalThis.fetch = (async () => verifiedResponse()) as typeof fetch + + const result = await findCertifiedSolanaProof('unsigned-fixture', 'relayDepositNative') + expect(result?.lutProof).toBeUndefined() + expect(result?.schema.signerKeyId).toBe(0x80) + expect(result?.schema.payload).toBe('4b4b534f4c534331') + expect(result?.certificate).toHaveLength(139 * 2) + }) + + test('preserves a nonempty LUT proof for an ALT-backed transaction', async () => { + process.env.CLEARSIGN_SERVICE_URL = 'http://127.0.0.1:1647' + globalThis.fetch = (async () => verifiedResponse({ + accounts: [Buffer.alloc(32, 0x11).toString('base64')], + signature: `0x${'44'.repeat(64)}`, + signerKeyId: 0x80, + })) as typeof fetch + + const result = await findCertifiedSolanaProof('unsigned-fixture', 'relayDepositNative') + expect(result?.lutProof?.accounts).toHaveLength(1) + expect(result?.lutProof?.signature).toBe('44'.repeat(64)) + }) + + test('rejects an empty or partial LUT proof instead of treating it as schema-only', async () => { + process.env.CLEARSIGN_SERVICE_URL = 'http://127.0.0.1:1647' + globalThis.fetch = (async () => verifiedResponse({ + accounts: [], + signature: `0x${'44'.repeat(64)}`, + signerKeyId: 0x80, + })) as typeof fetch + + await expect(findCertifiedSolanaProof('unsigned-fixture', 'relayDepositNative')) + .rejects.toThrow(/partial LUT proof/) + }) + + test('rejects malformed service material before it reaches hdwallet', async () => { + process.env.CLEARSIGN_SERVICE_URL = 'http://127.0.0.1:1647' + globalThis.fetch = (async () => { + const response = verifiedResponse() + const body = await response.json() as any + body.schema.signerKeyId = 3 + return new Response(JSON.stringify(body), { status: 200 }) + }) as typeof fetch + await expect(findCertifiedSolanaProof('unsigned-fixture', 'relayDepositNative')) + .rejects.toThrow(/non-certified signer id/) + + globalThis.fetch = (async () => verifiedResponse({ + accounts: [Buffer.alloc(31).toString('base64')], + signature: `0x${'44'.repeat(64)}`, + signerKeyId: 0x80, + })) as typeof fetch + await expect(findCertifiedSolanaProof('unsigned-fixture', 'relayDepositNative')) + .rejects.toThrow(/invalid LUT account/) + }) +}) diff --git a/projects/keepkey-vault/src/bun/solana-certified-registry.ts b/projects/keepkey-vault/src/bun/solana-certified-registry.ts new file mode 100644 index 00000000..a7e1319b --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-registry.ts @@ -0,0 +1,147 @@ +/** + * Certified Solana LUT/schema proof, fetched from the isolated ClearSign + * signer service per transaction. Mirrors evm-schema-registry.ts's + * findCertifiedEvmSchema — same service, same "unavailable/no-match is not + * an error" contract, same manufactures-no-trust-from-the-response shape. + * + * Unlike the EVM v3 envelope (one signed blob), the Solana certified path + * always needs a reusable instruction schema and its certificate. Messages + * that reference address lookup tables additionally need a transaction-bound + * LUT account attestation. They come from one /v1/solana/certify call. + */ +import { CERTIFIED_SOLANA_CATALOG } from './solana-certified-schema' + +/** + * Release builds use KeepKey's production ClearSign service by default. Local + * development can override it with CLEARSIGN_SERVICE_URL; either endpoint is + * untrusted input and the device still verifies every certificate and proof. + */ +export const DEFAULT_CLEARSIGN_SERVICE_URL = 'https://keepkey-clearsign.bithighlander.workers.dev' + +export interface CertifiedSolanaProof { + /** Present only when the message actually references address lookup tables. */ + lutProof?: { + accounts: string[] // base64, 32 bytes each + signature: string // hex, no 0x prefix + signerKeyId: number + } + schema: { + payload: string // hex, no 0x prefix + signature: string // hex, no 0x prefix + signerKeyId: number + } + certificate: string // hex, no 0x prefix +} + +function strip0x(value: string): string { + return value.startsWith('0x') ? value.slice(2) : value +} + +function requireHex(value: unknown, bytes: number | undefined, label: string): string { + const hex = strip0x(String(value ?? '')) + if (!hex || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) { + throw new Error(`ClearSign verification service returned invalid ${label}`) + } + if (bytes !== undefined && hex.length !== bytes * 2) { + throw new Error(`ClearSign verification service returned invalid ${label} length`) + } + return hex +} + +function requireAccount(value: unknown): string { + const account = String(value ?? '') + const decoded = Buffer.from(account, 'base64') + // Buffer's decoder is intentionally permissive; round-trip the canonical + // spelling so malformed text cannot silently decode to a different key. + if (decoded.length !== 32 || decoded.toString('base64') !== account) { + throw new Error('ClearSign verification service returned an invalid LUT account') + } + return account +} + +/** + * Ask the isolated signer service to certify this exact Solana transaction + * against a reviewed catalog entry. Returns undefined (never throws for a + * routine miss) when the service is unreachable, unconfigured, or the + * instruction doesn't match any catalog entry — callers fall back to the + * existing runtime-schema/consent path. Self-contained legacy/v0 messages + * intentionally return a certified schema + certificate without `lutProof`; + * manufacturing an empty lookup proof would conflate two different security + * claims and is rejected by firmware. + */ +export async function findCertifiedSolanaProof( + rawTxBase64: string, + catalogKey: string, +): Promise { + if (!(catalogKey in CERTIFIED_SOLANA_CATALOG)) return undefined + const base = String(process.env.CLEARSIGN_SERVICE_URL || DEFAULT_CLEARSIGN_SERVICE_URL) + .trim() + .replace(/\/+$/, '') + + let response: Response + try { + response = await fetch(`${base}/v1/solana/certify`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ rawTx: rawTxBase64, catalogKey }), + signal: AbortSignal.timeout(10_000), + }) + } catch (error: any) { + throw new Error(`ClearSign verification service is unavailable: ${error?.message || 'connection failed'}`) + } + let result: any + try { + result = await response.json() + } catch { + throw new Error(`ClearSign verification service returned HTTP ${response.status} without valid JSON`) + } + if (!response.ok) { + if (response.status === 422) { + console.log(`[swap] certified Solana proof declined (422): ${result?.error || 'no reason given'}`) + return undefined + } + throw new Error(`ClearSign verification service returned HTTP ${response.status}: ${result?.error || 'request failed'}`) + } + if ( + result?.classification !== 'VERIFIED' || + !result?.schema?.payload || + !result?.schema?.signature || + !result?.certificate + ) { + throw new Error('ClearSign verification service returned an incomplete certified proof') + } + + const hasAnyLutProof = result?.lutProof !== undefined + if (hasAnyLutProof && ( + !result?.lutProof?.signature || + !Array.isArray(result?.lutProof?.accounts) || + result.lutProof.accounts.length === 0 + )) { + throw new Error('ClearSign verification service returned a partial LUT proof') + } + + if (result.schema.signerKeyId !== 0x80 || + (hasAnyLutProof && result.lutProof.signerKeyId !== 0x80)) { + throw new Error('ClearSign verification service returned a non-certified signer id') + } + + const schemaPayload = requireHex(result.schema.payload, undefined, 'schema payload') + const schemaSignature = requireHex(result.schema.signature, 64, 'schema signature') + const certificate = requireHex(result.certificate, 139, 'certificate') + + return { + ...(hasAnyLutProof ? { + lutProof: { + accounts: result.lutProof.accounts.map(requireAccount), + signature: requireHex(result.lutProof.signature, 64, 'LUT signature'), + signerKeyId: result.lutProof.signerKeyId, + }, + } : {}), + schema: { + payload: schemaPayload, + signature: schemaSignature, + signerKeyId: result.schema.signerKeyId, + }, + certificate, + } +} diff --git a/projects/keepkey-vault/src/bun/solana-certified-schema.test.ts b/projects/keepkey-vault/src/bun/solana-certified-schema.test.ts new file mode 100644 index 00000000..803501f5 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-schema.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from 'bun:test' +import { randomBytes } from 'node:crypto' + +import { + serializeSolanaSchema, + solanaSchemaCoverage, + signCertifiedSolanaSchema, + CERTIFIED_SOLANA_CATALOG, + ARG_LAMPORTS, +} from './solana-certified-schema' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sdkFixture = require('../../../keepkey-sdk/tests/fixtures/solana-schema') + +// Real Solana scope-501 certificate issued 2026-08-24 by the master root +// signer (docs/certs/solana-scope-501-certificate.json). Public data only. +const SOLANA_CERT_HEX = '0101000001f56c68c8804b6565704b6579205661756c74000000000000000000000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea02b2dc9f48abcd2e46d4850cfa2753fac6068a45747a32a4a39f249af72b55370f3491913b7fb9a80207d619b3b4fca6750fc1fdc790da5562b42a351e12cde3c0f084056a24ca8d1bf2c36b5' + +describe('serializeSolanaSchema', () => { + test('is byte-for-byte identical to the SDK offline fixture (drift gate)', () => { + const oursNative = serializeSolanaSchema(CERTIFIED_SOLANA_CATALOG.relayDepositNative) + const theirsNative = sdkFixture.serializeSchema(sdkFixture.CATALOG.relayDepositNative) + expect(Buffer.from(oursNative)).toEqual(Buffer.from(theirsNative)) + + const oursToken = serializeSolanaSchema(CERTIFIED_SOLANA_CATALOG.relayDepositToken) + const theirsToken = sdkFixture.serializeSchema(sdkFixture.CATALOG.relayDepositToken) + expect(Buffer.from(oursToken)).toEqual(Buffer.from(theirsToken)) + }) + + test('coverage exactly matches the real 48-byte Relay instruction data', () => { + expect(solanaSchemaCoverage(CERTIFIED_SOLANA_CATALOG.relayDepositNative)).toBe(48) + expect(CERTIFIED_SOLANA_CATALOG.relayDepositNative.args?.[0].type).toBe(ARG_LAMPORTS) + }) + + test('the SDK fixture round-trip-decodes what we serialize', () => { + const payload = serializeSolanaSchema(CERTIFIED_SOLANA_CATALOG.relayDepositNative) + const decoded = sdkFixture.decodeSchema(payload) + expect(decoded.instructionName).toBe('depositNative') + expect(decoded.args.length).toBe(2) + }) +}) + +describe('signCertifiedSolanaSchema', () => { + test('accepts the real Solana-scoped certificate but rejects a mismatched private key', () => { + const wrongKey = randomBytes(32).toString('hex') + expect(() => signCertifiedSolanaSchema(SOLANA_CERT_HEX, wrongKey, CERTIFIED_SOLANA_CATALOG.relayDepositNative)) + .toThrow(/does not match reviewed signer/) + }) +}) diff --git a/projects/keepkey-vault/src/bun/solana-certified-schema.ts b/projects/keepkey-vault/src/bun/solana-certified-schema.ts new file mode 100644 index 00000000..97a6953a --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-certified-schema.ts @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto' +import { utils as ethersUtils } from 'ethers' +import bs58 from 'bs58' + +import { + ALPHA_DELEGATE_PUBLIC_KEY, + ALPHA_DELEGATE_FINGERPRINT, + CLEARSIGN_SCOPE_SOLANA, + inspectAlphaCertificate, +} from './clearsign-alpha-ceremony' + +/** + * Certified KKSOLSC1 instruction-schema builder + signer. + * + * A schema describes how to read ONE program instruction — program id, + * discriminator, and labelled args/accounts to display. It carries no + * amounts and no transaction hash, so a signer attests it ONCE per + * program+instruction and every later transaction reuses it. + * + * Wire layout mirrors keepkey-firmware lib/firmware/solana.c + * (solana_parseInstrSchema) byte-for-byte, and matches the offline gate at + * keepkey-sdk/tests/fixtures/solana-schema.js — keep all three in sync. + */ + +const MAGIC = Buffer.from('KKSOLSC1', 'ascii') +const SCHEMA_VERSION = 1 + +const NAME_MAX = 20 +const LABEL_MAX = 16 +const MAX_ARGS = 4 +const MAX_ACCOUNTS = 4 +const DISC_MAX = 8 +const MAX_PAYLOAD_BYTES = 256 // SolanaSignTx.schema_payload max_size + +export const ARG_U64 = 1 +export const ARG_U8 = 2 +export const ARG_PUBKEY = 3 +export const ARG_OPAQUE32 = 4 +export const ARG_LAMPORTS = 5 + +const ARG_WIDTH: Record = { + [ARG_U64]: 8, + [ARG_U8]: 1, + [ARG_PUBKEY]: 32, + [ARG_OPAQUE32]: 32, + [ARG_LAMPORTS]: 8, +} + +export interface SolanaSchemaArg { + type: number + label: string +} + +export interface SolanaSchemaAccount { + index: number + label: string +} + +export interface SolanaSchemaSpec { + programId: string // base58 + discriminator: Buffer + programName: string + instructionName: string + args?: SolanaSchemaArg[] + accounts?: SolanaSchemaAccount[] +} + +/** Display text must be printable ASCII, no '%' (device screen safety). */ +function lenPrefixedText(value: string, maxLength: number, name: string): Buffer { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string`) + if (value.length > maxLength) throw new Error(`${name} exceeds ${maxLength} chars`) + for (const ch of value) { + const cp = ch.codePointAt(0)! + if (cp < 0x20 || cp > 0x7e || ch === '%') throw new Error(`${name} contains a character the device will not display`) + } + const bytes = Buffer.from(value, 'ascii') + return Buffer.concat([Buffer.from([bytes.length]), bytes]) +} + +/** Serialize a KKSOLSC1 payload. Byte-for-byte match to the firmware parser. */ +export function serializeSolanaSchema(spec: SolanaSchemaSpec): Buffer { + const programId = Buffer.from(bs58.decode(spec.programId)) + if (programId.length !== 32) throw new Error('programId must decode to 32 bytes') + const disc = Buffer.from(spec.discriminator) + if (disc.length < 1 || disc.length > DISC_MAX) throw new Error(`discriminator must be 1..${DISC_MAX} bytes`) + const args = spec.args || [] + const accounts = spec.accounts || [] + if (args.length > MAX_ARGS) throw new Error(`at most ${MAX_ARGS} args`) + if (accounts.length > MAX_ACCOUNTS) throw new Error(`at most ${MAX_ACCOUNTS} accounts`) + + const parts: Buffer[] = [ + MAGIC, + Buffer.from([SCHEMA_VERSION]), + programId, + Buffer.from([disc.length]), + disc, + lenPrefixedText(spec.programName, NAME_MAX, 'programName'), + lenPrefixedText(spec.instructionName, NAME_MAX, 'instructionName'), + Buffer.from([args.length]), + ] + for (const arg of args) { + if (!ARG_WIDTH[arg.type]) throw new Error(`unknown arg type ${arg.type}`) + parts.push(Buffer.from([arg.type]), lenPrefixedText(arg.label, LABEL_MAX, 'arg label')) + } + parts.push(Buffer.from([accounts.length])) + for (const acct of accounts) { + if (!Number.isInteger(acct.index) || acct.index < 0 || acct.index > 255) { + throw new Error('account index must be a byte') + } + parts.push(Buffer.from([acct.index]), lenPrefixedText(acct.label, LABEL_MAX, 'account label')) + } + const payload = Buffer.concat(parts) + if (payload.length > MAX_PAYLOAD_BYTES) throw new Error(`payload ${payload.length}B exceeds the ${MAX_PAYLOAD_BYTES}B proto cap`) + return payload +} + +/** Bytes the schema claims to account for: discriminator + every arg width. */ +export function solanaSchemaCoverage(spec: SolanaSchemaSpec): number { + return spec.discriminator.length + (spec.args || []).reduce((n, a) => n + ARG_WIDTH[a.type], 0) +} + +function hexBytes(value: string, length: number, label: string): Buffer { + const clean = String(value || '').replace(/^0x/i, '') + if (!/^[0-9a-fA-F]+$/.test(clean) || clean.length !== length * 2) { + throw new Error(`${label} must be exactly ${length} bytes of hex`) + } + return Buffer.from(clean, 'hex') +} + +export interface CertifiedSolanaSchema { + schemaPayload: string // 0x-prefixed + schemaSignature: string // 0x-prefixed 64-byte compact secp256k1 + keyId: number + fingerprint: string + alias: string + certificateHex: string +} + +/** Sign a KKSOLSC1 schema for the certified path, using the Solana-scoped + * (501) delegate certificate and its private key. The private key never + * leaves this process. */ +export function signCertifiedSolanaSchema( + certificateHex: string, + delegatePrivateKeyHex: string, + spec: SolanaSchemaSpec, +): CertifiedSolanaSchema { + const certificate = hexBytes(certificateHex, 139, 'solana certificate') + const certInfo = inspectAlphaCertificate(certificate.toString('hex')) + if (certInfo.chainId !== CLEARSIGN_SCOPE_SOLANA) { + throw new Error(`certificate is scoped to ${certInfo.chainId}, not Solana (${CLEARSIGN_SCOPE_SOLANA})`) + } + const privateKey = hexBytes(delegatePrivateKeyHex, 32, 'delegate private key') + const signingKey = new ethersUtils.SigningKey(`0x${privateKey.toString('hex')}`) + const publicKey = ethersUtils.computePublicKey(signingKey.publicKey, true).slice(2).toLowerCase() + if (publicKey !== ALPHA_DELEGATE_PUBLIC_KEY) { + throw new Error(`delegate private key does not match reviewed signer ${ALPHA_DELEGATE_FINGERPRINT}`) + } + + const payload = serializeSolanaSchema(spec) + const digest = createHash('sha256').update(payload).digest() + const signature = signingKey.signDigest(`0x${digest.toString('hex')}`) + const compact = Buffer.concat([ + hexBytes(signature.r, 32, 'signature r'), + hexBytes(signature.s, 32, 'signature s'), + ]) + return { + schemaPayload: `0x${payload.toString('hex')}`, + schemaSignature: `0x${compact.toString('hex')}`, + keyId: 0x80, + fingerprint: ALPHA_DELEGATE_FINGERPRINT, + alias: certInfo.alias, + certificateHex: certificate.toString('hex'), + } +} + +/** + * Reviewed catalog. Real, captured instruction shapes from api.relay.link + * (2026-07-27) — both 48 bytes: 8-byte discriminator + u64 amount (LE) + + * 32-byte order id. Mirrors keepkey-sdk/tests/fixtures/solana-schema.js. + */ +export const CERTIFIED_SOLANA_CATALOG: Record = { + relayDepositNative: { + programId: '99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2', + discriminator: Buffer.from('0d9e0ddf5fd51c06', 'hex'), + programName: 'Relay Bridge', + instructionName: 'depositNative', + args: [ + { type: ARG_LAMPORTS, label: 'Amount' }, + { type: ARG_OPAQUE32, label: 'Order' }, + ], + accounts: [{ index: 3, label: 'Vault' }], + }, + relayDepositToken: { + programId: '99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2', + discriminator: Buffer.from('0b9c60da27a3b413', 'hex'), + programName: 'Relay Bridge', + instructionName: 'depositToken', + args: [ + { type: ARG_U64, label: 'Amount' }, + { type: ARG_OPAQUE32, label: 'Order' }, + ], + accounts: [{ index: 3, label: 'Vault' }], + }, +} diff --git a/projects/keepkey-vault/src/bun/solana-lut-resolver.test.ts b/projects/keepkey-vault/src/bun/solana-lut-resolver.test.ts new file mode 100644 index 00000000..ca72009d --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-lut-resolver.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from 'bun:test' +import bs58 from 'bs58' + +import { resolveCanonicalLutAccounts, SolanaLutCanonicalizationError } from './solana-lut-resolver' +import { SolanaAltResolveError } from './solana-alt' +import type { ParsedSolanaMessage, SolanaAltEntry } from './solana-tx' +import type { AltAccountFetcher } from './solana-alt' + +function pubkey(seed: number): Buffer { + return Buffer.alloc(32, seed) +} + +function b58(buf: Buffer): string { + return bs58.encode(buf) +} + +function fakeMessage(altEntries: SolanaAltEntry[]): ParsedSolanaMessage { + return { + version: 'v0', + header: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 }, + staticAccounts: [], + recentBlockhash: pubkey(0xff), + instructions: [], + altEntries, + } +} + +/** Table with `count` sequentially-seeded 32-byte addresses. */ +function fakeTable(tableSeed: number, count: number): { key: Buffer; addresses: Buffer[] } { + const key = pubkey(tableSeed) + const addresses = Array.from({ length: count }, (_, i) => pubkey(tableSeed * 100 + i)) + return { key, addresses } +} + +function fetcherFor(tables: Array<{ key: Buffer; addresses: Buffer[] }>): AltAccountFetcher { + return async (altPubkeysBase58: string[]) => { + return altPubkeysBase58.map((k) => { + const table = tables.find((t) => b58(t.key) === k) + if (!table) return null + // ALT account bytes: 56-byte header + 32*N addresses. + const header = Buffer.alloc(56) + header.writeUInt32LE(1, 0) // discriminator = LookupTable + const data = Buffer.concat([header, ...table.addresses]) + return { data, owner: 'AddressLookupTab1e1111111111111111111111111' } + }) + } +} + +describe('resolveCanonicalLutAccounts', () => { + test('orders writable-then-readonly across multiple tables, preserving table and index order', async () => { + const tableA = fakeTable(1, 4) + const tableB = fakeTable(2, 4) + const message = fakeMessage([ + { accountKey: tableA.key, writableIndices: [1, 0], readonlyIndices: [2] }, + { accountKey: tableB.key, writableIndices: [3], readonlyIndices: [0, 1] }, + ]) + const result = await resolveCanonicalLutAccounts(message, fetcherFor([tableA, tableB])) + expect(result.writableCount).toBe(3) + expect(result.readonlyCount).toBe(3) + expect(result.accounts.length).toBe(6) + // writable: A[1], A[0], B[3] ; readonly: A[2], B[0], B[1] + expect(result.accounts[0]).toEqual(tableA.addresses[1]) + expect(result.accounts[1]).toEqual(tableA.addresses[0]) + expect(result.accounts[2]).toEqual(tableB.addresses[3]) + expect(result.accounts[3]).toEqual(tableA.addresses[2]) + expect(result.accounts[4]).toEqual(tableB.addresses[0]) + expect(result.accounts[5]).toEqual(tableB.addresses[1]) + }) + + test('rejects a missing/inactive table', async () => { + const tableA = fakeTable(1, 2) + const message = fakeMessage([{ accountKey: pubkey(99), writableIndices: [0], readonlyIndices: [] }]) + await expect(resolveCanonicalLutAccounts(message, fetcherFor([tableA]))).rejects.toThrow(SolanaAltResolveError) + }) + + test('rejects an out-of-range index', async () => { + const tableA = fakeTable(1, 2) + const message = fakeMessage([{ accountKey: tableA.key, writableIndices: [5], readonlyIndices: [] }]) + await expect(resolveCanonicalLutAccounts(message, fetcherFor([tableA]))).rejects.toThrow(/out of range/) + }) + + test('rejects a duplicate resolved account (ambiguous)', async () => { + const tableA = fakeTable(1, 2) + const message = fakeMessage([{ accountKey: tableA.key, writableIndices: [0], readonlyIndices: [0] }]) + await expect(resolveCanonicalLutAccounts(message, fetcherFor([tableA]))).rejects.toThrow(SolanaLutCanonicalizationError) + }) + + test('rejects more than 8 total resolved accounts', async () => { + const tableA = fakeTable(1, 10) + const message = fakeMessage([ + { accountKey: tableA.key, writableIndices: [0, 1, 2, 3, 4, 5, 6, 7, 8], readonlyIndices: [] }, + ]) + await expect(resolveCanonicalLutAccounts(message, fetcherFor([tableA]))).rejects.toThrow(/exceeding firmware/) + }) + + test('rejects a message with no address table lookups', async () => { + const message = fakeMessage([]) + await expect(resolveCanonicalLutAccounts(message, fetcherFor([]))).rejects.toThrow(SolanaLutCanonicalizationError) + }) +}) diff --git a/projects/keepkey-vault/src/bun/solana-lut-resolver.ts b/projects/keepkey-vault/src/bun/solana-lut-resolver.ts new file mode 100644 index 00000000..22258ec8 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-lut-resolver.ts @@ -0,0 +1,99 @@ +import bs58 from 'bs58' + +import type { ParsedSolanaMessage } from './solana-tx' +import { SolanaAltResolveError, resolveAlts, type AltAccountFetcher } from './solana-alt' + +/** + * Canonical LUT account resolution for the certified Solana ClearSign path. + * + * Firmware's KKSOLSW1 attestation preimage requires the resolved accounts in + * exactly the Solana runtime's own order: all writable lookup keys first, + * then all readonly lookup keys, walking the message's `address table + * lookups` (ALT entries) in their on-wire order, and each entry's indices in + * their own order. This mirrors `message.addressTableLookups` resolution in + * the Solana runtime itself — never reorder by instruction usage, and never + * accept extra keys the message doesn't actually reference (either would let + * a resolved account silently apply to the wrong instruction slot). + * + * Firmware's current cap is 8 accounts total (SOL_MAX_LUT_ACCOUNTS). + */ + +export const SOL_MAX_LUT_ACCOUNTS = 8 + +export class SolanaLutCanonicalizationError extends Error { + constructor(message: string) { + super(message) + this.name = 'SolanaLutCanonicalizationError' + } +} + +export interface CanonicalLutResolution { + /** Raw 32-byte account keys, writable-then-readonly, in canonical order. */ + accounts: Buffer[] + writableCount: number + readonlyCount: number +} + +/** + * Resolve every ALT entry in a parsed v0 message to the canonical account + * list firmware will attest and bind to. Throws on anything that would make + * the certified proof ambiguous or wrong: a missing/unresolvable table, an + * index the table doesn't have, a duplicate resolved key, or a count over + * the firmware cap. + */ +export async function resolveCanonicalLutAccounts( + message: ParsedSolanaMessage, + fetcher: AltAccountFetcher, +): Promise { + if (message.altEntries.length === 0) { + throw new SolanaLutCanonicalizationError('message has no address table lookups to resolve') + } + + const tableKeysBase58 = message.altEntries.map((entry) => bs58.encode(entry.accountKey)) + const resolved = await resolveAlts(tableKeysBase58, fetcher) + + const writable: Buffer[] = [] + const readonly: Buffer[] = [] + const seen = new Set() + + for (let i = 0; i < message.altEntries.length; i++) { + const entry = message.altEntries[i] + const tableKey = tableKeysBase58[i] + const addresses = resolved.get(tableKey) + if (!addresses) { + throw new SolanaAltResolveError( + `lookup table ${tableKey} is missing, inactive, or not owned by the ALT program`, + ) + } + + for (const idx of entry.writableIndices) { + if (idx < 0 || idx >= addresses.length) { + throw new SolanaAltResolveError(`lookup table ${tableKey}: writable index ${idx} out of range (table has ${addresses.length})`) + } + const key = addresses[idx] + if (seen.has(key)) throw new SolanaLutCanonicalizationError(`account ${key} resolved more than once (ambiguous)`) + seen.add(key) + writable.push(Buffer.from(bs58.decode(key))) + } + for (const idx of entry.readonlyIndices) { + if (idx < 0 || idx >= addresses.length) { + throw new SolanaAltResolveError(`lookup table ${tableKey}: readonly index ${idx} out of range (table has ${addresses.length})`) + } + const key = addresses[idx] + if (seen.has(key)) throw new SolanaLutCanonicalizationError(`account ${key} resolved more than once (ambiguous)`) + seen.add(key) + readonly.push(Buffer.from(bs58.decode(key))) + } + } + + const accounts = [...writable, ...readonly] + if (accounts.length === 0) { + throw new SolanaLutCanonicalizationError('address table lookups resolved to zero accounts') + } + if (accounts.length > SOL_MAX_LUT_ACCOUNTS) { + throw new SolanaLutCanonicalizationError( + `resolved ${accounts.length} lookup accounts, exceeding firmware's ${SOL_MAX_LUT_ACCOUNTS}-account cap`, + ) + } + return { accounts, writableCount: writable.length, readonlyCount: readonly.length } +} diff --git a/projects/keepkey-vault/src/bun/solana-token.ts b/projects/keepkey-vault/src/bun/solana-token.ts index 1cb49aa8..2cb31dc0 100644 --- a/projects/keepkey-vault/src/bun/solana-token.ts +++ b/projects/keepkey-vault/src/bun/solana-token.ts @@ -51,6 +51,50 @@ function formatTokenBaseUnits(value: bigint, decimals: number): string { return fraction ? `${whole}.${fraction}` : whole } +/** Lamports per SOL. Native SOL is always 9 decimals — it is not a mint and + * has no on-chain decimals field to read. */ +export const SOLANA_NATIVE_DECIMALS = 9 + +/** + * Direct, indexer-independent NATIVE SOL balance lookup. + * + * The SPL version below only answers for a mint. A swap whose destination is + * native SOL (`solana:/slip44:501`) has no mint, so it had no direct + * confirmation path at all and fell back entirely to Pioneer's portfolio + * indexer — which lags a completed swap by seconds to minutes. The dashboard + * kept showing the pre-swap balance across refreshes with nothing to say why. + */ +export async function getSolanaNativeBalance( + owner: string, + endpoint: string = DEFAULT_SOLANA_RPC_ENDPOINT, + fetchImpl: typeof fetch = fetch, +): Promise { + const res = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getBalance', + params: [owner, { commitment: 'confirmed' }], + }), + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) throw new Error(`Solana RPC balance lookup failed (${res.status})`) + const body = await res.json() as { error?: { message?: string }; result?: { value?: number } } + if (body.error) throw new Error(body.error.message || 'Solana RPC balance lookup failed') + const lamports = body.result?.value + // A funded-but-empty account legitimately reports 0. `undefined` means the + // node did not answer the question — never report that as an empty wallet. + if (typeof lamports !== 'number' || !Number.isFinite(lamports)) { + throw new Error('Solana RPC returned no balance value') + } + return { + amount: formatTokenBaseUnits(BigInt(lamports), SOLANA_NATIVE_DECIMALS), + decimals: SOLANA_NATIVE_DECIMALS, + } +} + /** * Direct, indexer-independent SPL balance lookup used after a swap completes. * An owner can have multiple token accounts for one mint, so sum raw base units diff --git a/projects/keepkey-vault/src/bun/swagger.json b/projects/keepkey-vault/src/bun/swagger.json index 1c694faa..40c50f18 100644 --- a/projects/keepkey-vault/src/bun/swagger.json +++ b/projects/keepkey-vault/src/bun/swagger.json @@ -3771,7 +3771,7 @@ "post": { "operationId": "solana_signTransaction", "summary": "Sign a Solana transaction", - "description": "Sign a raw Solana transaction on the KeepKey device. raw_tx is a base64-encoded serialized transaction. Self-contained v0 transactions are routed through the firmware transaction parser. An x402 caller may include the exact PaymentRequirements object so Vault can bind sponsor, mint, amount, signer and recipient ATA to the signed bytes before the device displays the verified merchant owner. Transactions that use address lookup tables remain behind explicit one-request blind-signing consent.", + "description": "Sign a raw Solana transaction on the KeepKey device. raw_tx is a base64-encoded serialized transaction. Self-contained legacy/v0 transactions may use a root-certified instruction schema; v0 transactions with address lookups additionally require a transaction-bound LUT proof. An x402 caller may include the exact PaymentRequirements object so Vault can bind sponsor, mint, amount, signer and recipient ATA to the signed bytes before the device displays the verified merchant owner. Unsupported routes remain behind explicit one-request blind-signing consent.", "parameters": [], "requestBody": { "content": { @@ -3814,31 +3814,48 @@ }, "required": ["scheme", "network", "amount", "asset", "payTo", "maxTimeoutSeconds", "extra"] }, - "swapMetadata": { + "lutProof": { "type": "object", - "description": "Transaction-bound KKSOLSW1 ClearSign descriptor signed by a device-trusted metadata key.", + "description": "Transaction-bound proof of the canonical accounts resolved from address lookup tables. Omit for self-contained messages.", "additionalProperties": false, "properties": { - "payload": { - "type": "string", - "description": "Base64-encoded canonical KKSOLSW1 descriptor" + "accounts": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string" }, + "description": "Base64-encoded 32-byte accounts in canonical writable-then-readonly order" }, "signature": { "type": "string", - "description": "Base64-encoded 64-byte compact secp256k1 signature over SHA256(payload)" + "description": "64-byte compact secp256k1 signature (hex or base64)" }, "signerKeyId": { "type": "integer", - "minimum": 0, - "maximum": 3, - "description": "Trusted device ClearSign signer slot" + "enum": [0, 1, 2, 3, 128], + "description": "Runtime signer slot 0-3, or 128 for a root-certified delegate" } }, "required": [ - "payload", + "accounts", "signature", "signerKeyId" ] + }, + "schema": { + "type": "object", + "description": "Reusable signed KKSOLSC1 instruction schema.", + "additionalProperties": false, + "properties": { + "payload": { "type": "string", "description": "Schema bytes (hex or base64)" }, + "signature": { "type": "string", "description": "64-byte compact secp256k1 signature (hex or base64)" }, + "signerKeyId": { "type": "integer", "enum": [0, 1, 2, 3, 128] } + }, + "required": ["payload", "signature", "signerKeyId"] + }, + "certificate": { + "type": "string", + "description": "139-byte KeepKey root certificate (hex or base64), required exactly when signerKeyId is 128" } }, "required": [ diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index ea5c1b84..14eb87ad 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -11,12 +11,20 @@ import { CHAINS, supportedBtcScriptTypes, btcAccountPath, evmAddressPath } from '../shared/chains' import type { ChainDef } from '../shared/chains' import type { SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult } from '../shared/types' -import { SOLANA_BLIND_SIGNING_REQUIRED } from '../shared/types' +import { SOLANA_BLIND_SIGNING_REQUIRED, evmAdvancedModeRequiredMessage } from '../shared/types' import { toDeviceError, deviceErrorMessage } from '../shared/device-error' import { findEvmSchema } from './evm-schema-registry' +import { findCertifiedEvmEnvelope } from './evm-certified-registry' +import { isCertifiedEvmMetadata } from './evm-certified-schema' +import { firmwareClearSigns } from './calldata-decoder' import { findSolanaSchema } from './solana-schema-registry' +import { findCertifiedSolanaProof } from './solana-certified-registry' +import { + hasCompleteCertifiedSolanaEnvelope, + supportsCertifiedClearSign, +} from './solana-certified-policy' import { getPioneer } from './pioneer' -import { encodeDepositWithExpiry, encodeApprove, parseUnits, toHex } from './txbuilder/evm' +import { encodeDepositWithExpiry, encodeApprove, parseUnits, toHex, readPioneerBalance } from './txbuilder/evm' import { getEvmGasPrice, getEvmFeeData, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20Balance, getErc20Decimals, broadcastEvmTx, EvmSignerVerificationError, waitForTxReceipt, estimateGas } from './evm-rpc' import * as txb from './txbuilder' import { normalizeBchAddress } from './txbuilder' @@ -552,6 +560,8 @@ export interface SwapContext { * Returns undefined when unknown (no cached features / policy not reported). * Used to gate Solana swaps, which can only blind-sign. */ isAdvancedModeEnabled?: () => boolean | undefined + /** Connected device firmware. Certified ClearSign authority starts at 7.16. */ + getFirmwareVersion?: () => string | undefined /** User's configured Solana RPC, for the host-side outflow check. */ getSolanaRpcEndpoint?: () => string | undefined /** Durable ClearSign evidence sink owned by Vault (no-op for callers that do not persist). */ @@ -674,7 +684,54 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // findSolanaSchema declines when the message still carries lookup // tables, since firmware will not apply a schema to accounts that are // absent from the signed bytes. - const solSchema = findSolanaSchema(params.relayTx.serializedTx) + // A schema the device CANNOT verify is worse than no schema at all. + // Firmware fails the whole request ("Invalid Solana instruction schema", + // fsm_msg_solana.h:803) and deliberately never degrades to blind signing: + // "Present-but-invalid schema material fails the request". Verification + // needs a signer loaded in RAM, and loaded signers are only honoured with + // AdvancedMode on -- so with AdvancedMode known-off it is a GUARANTEED + // hard reject, worded as if the transaction were malformed. + // + // Dropping it here routes into the opaque-consent path below, which asks + // the user to opt in. Perverse otherwise: a route WITHOUT a schema works + // (consent -> blind sign) while a route WITH one dies. + // The certified signer understands both real Relay shapes: v0 messages + // with address-table entries receive schema + LUT proof + certificate; + // self-contained legacy/v0 messages receive schema + certificate only. + // A routine catalog miss or unavailable service falls through to the + // existing runtime-schema/explicit-consent path. + let certifiedProof: Awaited> = undefined + const firmwareVersion = ctx.getFirmwareVersion?.() + if (supportsCertifiedClearSign(firmwareVersion)) { + try { + certifiedProof = await findCertifiedSolanaProof( + params.relayTx.serializedTx, + 'relayDepositNative', + ) + } catch (err: any) { + swapLog(`${TAG} certified Solana ClearSign proof unavailable: ${err?.message || err}`) + } + } else { + swapLog(`${TAG} certified Solana ClearSign skipped: firmware ${firmwareVersion || 'unknown'} < 7.16.0`) + } + if (certifiedProof) { + const shape = certifiedProof.lutProof + ? `${certifiedProof.lutProof.accounts.length} LUT accounts` + : 'self-contained message (no LUT proof)' + swapLog(`${TAG} certified Solana ClearSign proof attached: ${shape}`) + } + + const solSchemaAvailable = findSolanaSchema(params.relayTx.serializedTx) + // Withheld in two cases: AdvancedMode off (verification cannot succeed -- + // loaded signers are only honoured with it on), and after the user has + // explicitly consented to blind signing (re-attaching would refuse again + // and loop). + const solSchema = (certifiedProof || ctx.isAdvancedModeEnabled?.() === false || params.allowSolanaBlindSigning === true) + ? undefined + : solSchemaAvailable + if (solSchemaAvailable && !solSchema && !certifiedProof) { + swapLog(`${TAG} clear-sign schema withheld: AdvancedMode is off, the device could not verify it`) + } if (solSchema) { swapLog(`${TAG} clear-sign schema attached: ${solSchema.program}/${solSchema.instruction} (keyId=${solSchema.signerKeyId})`) } @@ -682,7 +739,13 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): addressNList: fromChain.defaultPath, rawTx: params.relayTx.serializedTx, allowBlindSigning: params.allowSolanaBlindSigning === true, - swapMetadata: params.relayTx.solanaSwapMetadata, + ...(certifiedProof + ? { + ...(certifiedProof.lutProof ? { lutProof: certifiedProof.lutProof } : {}), + schema: certifiedProof.schema, + certificate: certifiedProof.certificate, + } + : {}), ...(solSchema ? { schema: { payload: solSchema.payload, signature: solSchema.signature, signerKeyId: solSchema.signerKeyId } } : {}), @@ -896,23 +959,22 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): unsignedTx = buildResult.unsignedTx } - // Prebuilt Relay Solana payloads currently use a custom program plus - // lookup-table accounts. Without a transaction-bound ClearSign descriptor the - // device must classify that exact route as opaque. Ask for explicit one-shot - // consent before the hardware prompt, but do not block other Solana or v0 - // transactions that firmware can natively ClearSign. + // Prebuilt Relay Solana payloads use a custom program. Some real routes are + // self-contained v0 messages; others resolve accounts through lookup tables. + // Without a complete certified envelope (or a usable runtime schema), the + // device must classify the route as opaque. Ask for explicit one-shot consent + // before the hardware prompt, but do not block transactions firmware can + // natively ClearSign. const needsOpaqueSolanaFallback = fromChain.chainFamily === 'solana' && !!params.relayTx?.serializedTx && - !params.relayTx.solanaSwapMetadata && + !hasCompleteCertifiedSolanaEnvelope(unsignedTx) && // A reusable schema lets the device read this instruction, so no // blind-sign consent is needed. - !findSolanaSchema(params.relayTx.serializedTx) - if ( - needsOpaqueSolanaFallback && - ctx.isAdvancedModeEnabled?.() !== true && - params.allowSolanaBlindSigning !== true - ) { + // Must mirror the attach decision above: a schema we withheld is not a + // schema the device will use, so the opaque path still applies. + !(ctx.isAdvancedModeEnabled?.() !== false && findSolanaSchema(params.relayTx.serializedTx)) + const buildSolanaBlindSignRequirement = async (): Promise => { // The device can't verify this transaction, so check it here instead: // simulate against an independent RPC and report what actually leaves the // wallet. This catches a quote server that built a transaction differing @@ -1001,10 +1063,50 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): } catch (e: any) { swapLog(`${TAG} outflow check failed: ${e?.message}`) } - throw new Error(outflow + return new Error(outflow ? `${SOLANA_BLIND_SIGNING_REQUIRED} ${outflow}` : SOLANA_BLIND_SIGNING_REQUIRED) } + if ( + needsOpaqueSolanaFallback && + ctx.isAdvancedModeEnabled?.() !== true && + params.allowSolanaBlindSigning !== true + ) { + throw await buildSolanaBlindSignRequirement() + } + + // Same shape as the Solana gate above, for EVM contract calls the firmware + // cannot decode (relay/aggregator routes are the common case — they are not + // in the device's pinned allowlist, see firmwareClearSigns). + // + // Clear-signing is NOT an alternative here today. Metadata verification needs + // a runtime-loaded signer, and loading one ALSO requires AdvancedMode + // (firmware fsm_msg_ethereum.h + signed_metadata.c); there is no built-in + // trust anchor yet — docs/security/clearsign-key-delegation-roadmap.md tracks + // the delegation work that would remove this requirement. + // + // Without this check the device renders "Blocked", replies ActionCancelled, + // and hdwallet's transport (transport.ts) constructs a bare + // `new core.ActionCancelled()` that DISCARDS the firmware's reason string + // ("Blind signing disabled by policy"). The user is then shown a generic + // "Action cancelled" — indistinguishable from having pressed Cancel — for a + // policy refusal they were never told about. Check before we prompt, so the + // message names the actual blocker. + if ( + fromChain.chainFamily === 'evm' && + typeof unsignedTx?.data === 'string' && unsignedTx.data.length > 2 && + !firmwareClearSigns(unsignedTx.to, unsignedTx.data, Number(unsignedTx.chainId)) && + !isCertifiedEvmMetadata(unsignedTx.txMetadata) && + // Only when we KNOW it is off. `undefined` means the policy was not + // reported (no cached features), and guessing would block a swap the + // device would have signed. + ctx.isAdvancedModeEnabled?.() === false + ) { + swapLog(`${TAG} EVM blind-sign gate: to=${unsignedTx.to} selector=${String(unsignedTx.data).slice(0, 10)} — AdvancedMode is off`) + // Message content is load-bearing: SwapDialog routes on /AdvancedMode/i to + // the opt-in panel instead of a dead error. See evmAdvancedModeRequiredMessage. + throw new Error(evmAdvancedModeRequiredMessage(fromChain.coin)) + } // 4. Sign on device (user confirms tx details on hardware wallet) swapLog(`${TAG} Signing ${fromChain.chainFamily} tx via ${fromChain.signMethod}...`) @@ -1034,14 +1136,46 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): swapper: params.swapper, } : undefined let clearSignSentToDevice = false + const signOnDevice = (tx: any) => wrapSign( + () => { + clearSignSentToDevice = true + return txb.signTx(wallet, fromChain, tx) + }, + { operation: 'swap', chain: fromChain.coin, to: params.inboundAddress, value: params.amount, memo: params.memo }, + ) try { - signedTx = await wrapSign( - () => { - clearSignSentToDevice = true - return txb.signTx(wallet, fromChain, unsignedTx) - }, - { operation: 'swap', chain: fromChain.coin, to: params.inboundAddress, value: params.amount, memo: params.memo }, - ) + try { + signedTx = await signOnDevice(unsignedTx) + } catch (e: any) { + // The device refused the SCHEMA, not the transaction. + // + // Predicting whether it can verify our schema is unwinnable: it needs a + // signer loaded in RAM, Vault does not track which are loaded, and both + // that and AdvancedMode die on power cycle. Worse, gating on AdvancedMode + // alone inverts — turning AdvancedMode ON re-enables attachment, so the + // user fixes one refusal and immediately earns another. + // + // So stop predicting and react. Firmware validates schema material BEFORE + // it draws any confirm screen (fsm_msg_solana.h), so this failure cost the + // user nothing and they saw nothing. Drop the schema and sign the very + // same bytes the ordinary way. The schema was only ever an enhancement — + // better screens — and must never be the reason a swap cannot proceed. + if (unsignedTx?.schema && /Invalid Solana instruction schema/i.test(deviceErrorMessage(e))) { + // Do NOT silently re-sign without the schema. Dropping it means this + // transaction gets blind-signed, and the opaque-consent panel exists to + // show the user what it actually moves (host-side outflow simulation) + // before that happens. Skipping straight past it would trade a gate for + // a silent downgrade, which is worse. + // + // Ask instead. On the retry params.allowSolanaBlindSigning is true, + // which suppresses schema attachment above -- otherwise the schema would + // be re-attached, refused again, and the flow would loop. + swapLog(`${TAG} device refused the clear-sign schema — checking outflow before requesting blind-sign consent`) + throw await buildSolanaBlindSignRequirement() + } else { + throw e + } + } if (clearSignMaterial) onClearSignEvent?.({ outcome: 'signed', chain: fromChain.coin, ...clearSignMaterial, keyId: Number.isInteger(clearSignMaterial.keyId) ? clearSignMaterial.keyId : undefined, @@ -1472,8 +1606,7 @@ async function buildRelaySwapTx( 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) + nativeBalance = parseUnits(readPioneerBalance(bd, fromAddress), fromChain.decimals) } catch (e: any) { console.warn(`${TAG} Failed to fetch native balance via Pioneer for relay tx: ${e.message}`) } @@ -1617,7 +1750,8 @@ async function buildRelaySwapTx( // calldata it is about to sign — turning a blind-sign prompt into a labelled // review. Absent or mismatched: nothing is attached and behaviour is // unchanged, so this can never block a swap. - const evmSchema = findEvmSchema(chainId, relay.to, relay.data) + const certifiedEvmSchema = await findCertifiedEvmEnvelope(chainId, relay.to, relay.data) + const evmSchema = certifiedEvmSchema || findEvmSchema(chainId, relay.to, relay.data) if (evmSchema) { unsignedTx.txMetadata = { signedPayload: evmSchema.signedPayload, keyId: evmSchema.keyId } swapLog(`${TAG} clear-sign schema attached: ${evmSchema.method} (keyId=${evmSchema.keyId})`) @@ -1792,20 +1926,27 @@ async function buildEvmSwapTx( throw new Error(`Failed to fetch nonce for ${fromAddress} on ${fromChain.id} — cannot safely build swap transaction`) } - let nativeBalance = 0n + // A failed balance fetch is NOT a zero balance. Try RPC, then Pioneer, then + // give up loudly — defaulting to 0n produced "Insufficient ETH: need 1.65, + // have 0" on funded accounts whenever the public RPC hiccuped. Same posture + // as buildRelaySwapTx. + 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: ${e.message}`) } - } else { + } + if (nativeBalance === undefined) { try { const bd = await pioneer.GetBalanceAddressByNetwork({ networkId: fromChain.networkId, address: fromAddress }) - const balStr = String(bd?.data?.nativeBalance || bd?.data?.balance || '0') - nativeBalance = parseUnits(balStr, 18) + nativeBalance = parseUnits(readPioneerBalance(bd, fromAddress), fromChain.decimals) } catch (e: any) { console.warn(`${TAG} Failed to fetch balance via Pioneer: ${e.message}`) } } + if (nativeBalance === undefined) { + throw new Error(`Unable to verify ${fromChain.symbol} balance for ${fromAddress} — refusing to build the swap. Check your connection and try again.`) + } let approvalTxid: string | undefined diff --git a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts index 94576a42..1c85e17e 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts @@ -9,6 +9,25 @@ import type { ChainDef } from '../../shared/chains' const TAG = '[txbuilder:cosmos]' +/** + * Read a balance out of a Pioneer GetPortfolioBalances response. + * + * The `?? '0'` this replaces is the same failed-fetch-reads-as-zero bug fixed + * for EVM in #411/#414 (see readPioneerBalance in ./evm). Every call site here + * is a MAX send, so a missing balance became `0 - fee` → clamped to 0 → the + * user hit MAX on a funded account and was told "Amount must be greater than + * zero". Fails closed, but names the wrong cause and hides a server fault. + * + * A real numeric 0 is a verified empty account and passes through untouched. + */ +export function readCosmosBalance(resp: any, context: string): string { + const raw = resp?.data?.balances?.[0]?.balance + if (raw === undefined || raw === null || String(raw).trim() === '') { + throw new Error(`Unable to verify ${context} balance: the balance server returned no balance field`) + } + return String(raw) +} + /** Convert a decimal string (e.g. "1.5") to base units using integer math only. */ function toBaseUnits(displayAmount: string, decimals: number): bigint { const parts = displayAmount.split('.') @@ -168,12 +187,21 @@ export async function buildCosmosTx( if (isToken) { // Token MAX: send the whole token balance. The native fee is paid from the // chain's native coin (rune), a separate balance — so no reserve here. - const balStr = params.tokenBalance - ?? String((await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: params.caip, pubkey: fromAddress }] }, { forceRefresh: true }))?.data?.balances?.[0]?.balance ?? '0') + // Trust the frontend's balance only when it is an actual figure. `??` + // let a displayed '0' win outright — and '0' is exactly what the UI holds + // for a chain whose balance fetch failed, so the guard below was skipped + // by the one input most likely to be wrong. buildEvmTx and the Solana + // path both gate on `> 0` and re-fetch otherwise; match them. + const balStr = params.tokenBalance && parseFloat(params.tokenBalance) > 0 + ? params.tokenBalance + : readCosmosBalance( + await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: params.caip, pubkey: fromAddress }] }, { forceRefresh: true }), + `${chain.coin} token`, + ) baseAmount = toBaseUnits(String(balStr), amountDecimals) } else { const balResp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: fromAddress }] }, { forceRefresh: true }) - const balStr = String((balResp?.data?.balances || [])[0]?.balance ?? '0') + const balStr = readCosmosBalance(balResp, chain.coin) const balBase = toBaseUnits(balStr, chain.decimals) const feeBase = maxFeeReserveBase(chain, BigInt(fee.amount[0]?.amount || '0')) baseAmount = balBase - feeBase @@ -282,7 +310,7 @@ export async function buildCosmosStakingTx( if (isMax) { // Delegate all available balance minus the network fee (matches buildCosmosTx send-max) const balResp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: fromAddress }] }, { forceRefresh: true }) - const balStr = String((balResp?.data?.balances || [])[0]?.balance ?? '0') + const balStr = readCosmosBalance(balResp, chain.coin) const balBase = toBaseUnits(balStr, chain.decimals) // Same headroom rationale as buildCosmosTx (see maxFeeReserveBase). This // builder doesn't apply a feeLevel multiplier, so fee.amount is the actual diff --git a/projects/keepkey-vault/src/bun/txbuilder/evm.ts b/projects/keepkey-vault/src/bun/txbuilder/evm.ts index 5d6f5af2..43db5188 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/evm.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/evm.ts @@ -19,6 +19,27 @@ export function parseUnits(amount: string, decimals: number): bigint { return BigInt(whole + padded) } +/** Pull a balance string out of a Pioneer balance response, or throw. + * + * A response that arrives with no balance field is a FAILED LOOKUP, not an + * empty account. Every caller here feeds a signing decision, so the two must + * never be conflated: `|| '0'` is how a funded wallet ends up reported as + * "have 0" — or worse, on the relay path, as "your quote was built for a + * different address". Callers catch this and fall through to their own + * "unable to verify" error. */ +export function readPioneerBalance(resp: any, context: string): string { + // Try both field names before giving up — the old `a || b || '0'` chain fell + // through a blank `nativeBalance` to `balance`, and only the `'0'` tail was + // wrong. Keep the fallthrough, drop the invented zero. A numeric 0 is a + // VERIFIED empty account and must survive. + for (const raw of [resp?.data?.nativeBalance, resp?.data?.balance]) { + if (raw == null) continue + const s = String(raw).trim() + if (s !== '') return s + } + throw new Error(`no balance field in Pioneer response for ${context}`) +} + export const toHex = (value: bigint | number): string => { let hex = BigInt(value).toString(16) if (hex.length % 2) hex = '0' + hex @@ -211,19 +232,24 @@ export async function buildEvmTx( throw new Error(`Failed to fetch nonce for ${fromAddress} on ${chain.coin} — cannot safely build transaction`) } - // 3. Native balance (needed for gas in both native and ERC-20 paths) - let nativeBalance = 0n + // 3. Native balance (needed for gas in both native and ERC-20 paths). + // A failed fetch is NOT a zero balance — fall back to Pioneer, then fail + // loudly rather than reporting a funded account as empty. + let nativeBalance: bigint | undefined if (rpcUrl) { try { nativeBalance = await getEvmBalance(rpcUrl, fromAddress) } catch { console.warn(`${TAG} Direct RPC balance failed`) } - } else { + } + if (nativeBalance === undefined) { try { const balData = await pioneer.GetBalanceAddressByNetwork({ networkId: chain.networkId, address: fromAddress }) - const balStr = String(balData?.data?.nativeBalance || balData?.data?.balance || '0') - nativeBalance = parseUnits(balStr, 18) + nativeBalance = parseUnits(readPioneerBalance(balData, fromAddress), 18) } catch { console.warn(`${TAG} Balance API failed`) } } + if (nativeBalance === undefined) { + throw new Error(`Unable to verify ${chain.symbol} balance for ${fromAddress} — cannot safely build transaction`) + } // ── ERC-20 token transfer ─────────────────────────────────────────── if (isErc20) { @@ -280,7 +306,7 @@ export async function buildEvmTx( address: fromAddress, contractAddress, }) - tokBalStr = String(tokBalResp?.data?.balance || '0') + tokBalStr = readPioneerBalance(tokBalResp, `${contractAddress} balance`) console.log(`${TAG} Fetched token balance from API for max: ${tokBalStr}`) } catch (e: any) { throw new Error(`Cannot fetch token balance for max send: ${e.message}`) @@ -328,7 +354,11 @@ export async function buildEvmTx( 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) { + // No `&& nativeBalance > 0n` escape hatch. That guard existed to keep a + // failed balance fetch (which used to land as 0n) from blocking a send — + // an unverifiable balance now throws above, so the only way to reach here + // with 0n is a genuinely empty account, which must fail this check. + if (amountWei + gasFee > nativeBalance) { throw new Error( `Insufficient funds: balance ${Number(nativeBalance) / 1e18} ${chain.symbol}, ` + `need ${Number(amountWei + gasFee) / 1e18} ${chain.symbol} (incl gas)`, diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts index ededc72c..46f172ed 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts @@ -344,6 +344,9 @@ export async function estimateUtxoFee( const settled = await Promise.allSettled( allXpubs.map(x => fetchUtxosForXpub(pioneer, chain.networkId, x.xpub, x.scriptType, x.accountPath)) ) + // A partial set produces a confidently wrong fee and net-spendable figure. + // Callers already degrade gracefully on null, so no estimate beats a lie. + if (settled.some(r => r.status === 'rejected')) return null utxos = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []) } else { utxos = await fetchUtxosForXpub(pioneer, chain.networkId, primaryXpub, scriptType, accountPath) @@ -417,6 +420,11 @@ export async function buildUtxoTx( // 1. Fetch UTXOs — aggregate from all xpubs if provided, otherwise single xpub let utxos: any[] + // Tolerating a failed xpub (below) keeps a send buildable, but it also means + // `utxos` may be a strict subset of what the wallet holds. Every downstream + // statement about the total has to know that, or it asserts a number it + // cannot back — the same failed-fetch-reads-as-zero class as #411/#414. + let unreachableXpubs = 0 if (allXpubs && allXpubs.length > 0) { console.log(`${TAG} Multi-xpub aggregation: ${allXpubs.length} xpubs`) // Finding 5: tolerate individual xpub failures — use allSettled @@ -428,9 +436,19 @@ export async function buildUtxoTx( if (settled[i].status === 'fulfilled') { utxos.push(...(settled[i] as PromiseFulfilledResult).value) } else { + unreachableXpubs++ console.warn(`${TAG} ListUnspent failed for ${allXpubs[i].xpub.slice(0, 12)}...: ${(settled[i] as PromiseRejectedResult).reason?.message}`) } } + // "Send max" means "spend everything". With an account we could not read, + // the sweep would silently leave those coins behind and still call itself + // max — a wrong amount signed by the user, not merely a wrong message. + if (unreachableXpubs > 0 && isMax) { + throw new Error( + `Cannot send max: ${unreachableXpubs} of ${allXpubs.length} ${chain.coin} accounts could not be reached, ` + + `so the full balance is unknown. Try again once the balance server responds.`, + ) + } } else { utxos = await fetchUtxosForXpub(pioneer, chain.networkId, primaryXpub, scriptType, accountPath || undefined) } @@ -456,6 +474,15 @@ export async function buildUtxoTx( ) } } + // An empty set means "you have nothing" only when we actually managed to look. + // Otherwise the "still confirming" advice below invents an explanation for a + // network failure and sends the user off to wait for a nonexistent tx. + if (!utxos.length && unreachableXpubs > 0) { + throw new Error( + `Unable to read your ${chain.coin} balance — ${unreachableXpubs} account lookup(s) failed. ` + + `This is a balance server problem, not an empty wallet. Try again in a moment.`, + ) + } if (!utxos.length) throw new Error(`No confirmed UTXOs found for ${chain.coin}. If you recently sent or received ${chain.symbol}, the transaction may still be confirming — please wait and try again.`) // Diagnostic: dump raw UTXO[0] @@ -487,6 +514,16 @@ export async function buildUtxoTx( if (!result?.inputs) { const total = utxos.reduce((s: number, u: any) => s + u.value, 0) + // "have X" is a claim about the whole wallet. With an unreachable account + // it is a claim about a subset, and the user gets told they are short on a + // wallet that is not — exactly the "Insufficient ETH ... have 0" report + // that started this. Name the gap instead of quoting a number. + if (total < satoshis && unreachableXpubs > 0) { + throw new Error( + `Cannot verify your ${chain.coin} balance — ${unreachableXpubs} account lookup(s) failed, ` + + `so the ${total / 1e8} ${chain.symbol} found so far may not be all of it. Try again in a moment.`, + ) + } if (total < satoshis) throw new Error(`Insufficient funds: have ${total / 1e8}, need ${satoshis / 1e8} ${chain.symbol}`) throw new Error('Coin selection failed (possibly high fees)') } diff --git a/projects/keepkey-vault/src/bun/zcash-capability.test.ts b/projects/keepkey-vault/src/bun/zcash-capability.test.ts new file mode 100644 index 00000000..6014bc7d --- /dev/null +++ b/projects/keepkey-vault/src/bun/zcash-capability.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'bun:test' + +import { supportsZcashPrivacyBuild } from './zcash-capability' + +describe('Zcash release capability', () => { + test('requires both firmware support and a packaged sidecar', () => { + expect(supportsZcashPrivacyBuild('7.16.0', undefined)).toBe(false) + expect(supportsZcashPrivacyBuild('7.14.1', '/app/zcash-cli')).toBe(false) + expect(supportsZcashPrivacyBuild('7.15.0', '/app/zcash-cli')).toBe(true) + }) +}) diff --git a/projects/keepkey-vault/src/bun/zcash-capability.ts b/projects/keepkey-vault/src/bun/zcash-capability.ts new file mode 100644 index 00000000..cfdebedf --- /dev/null +++ b/projects/keepkey-vault/src/bun/zcash-capability.ts @@ -0,0 +1,10 @@ +import { versionCompare } from '../shared/firmware-versions' + +/** Zcash privacy requires both device support and a native sidecar compatible + * with this app build. Transparent Zcash remains available when this is false. */ +export function supportsZcashPrivacyBuild( + firmwareVersion: string | undefined, + sidecarBinary: string | undefined, +): boolean { + return !!sidecarBinary && !!firmwareVersion && versionCompare(firmwareVersion, '7.15.0') >= 0 +} diff --git a/projects/keepkey-vault/src/bun/zcash-sidecar-path.test.ts b/projects/keepkey-vault/src/bun/zcash-sidecar-path.test.ts new file mode 100644 index 00000000..25b6f4a8 --- /dev/null +++ b/projects/keepkey-vault/src/bun/zcash-sidecar-path.test.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { findZcashCliBinary } from './zcash-sidecar' + +const originalOverride = process.env.ZCASH_CLI_BIN + +afterEach(() => { + if (originalOverride === undefined) delete process.env.ZCASH_CLI_BIN + else process.env.ZCASH_CLI_BIN = originalOverride +}) + +describe('Zcash sidecar release capability', () => { + test('honors an existing explicit binary override', () => { + process.env.ZCASH_CLI_BIN = process.execPath + expect(findZcashCliBinary()).toBe(process.execPath) + }) +}) diff --git a/projects/keepkey-vault/src/bun/zcash-sidecar.ts b/projects/keepkey-vault/src/bun/zcash-sidecar.ts index f77d28d3..f1650bdf 100644 --- a/projects/keepkey-vault/src/bun/zcash-sidecar.ts +++ b/projects/keepkey-vault/src/bun/zcash-sidecar.ts @@ -50,17 +50,13 @@ let cachedReleaseBlock: number | null = null * * Throws if the binary cannot be found anywhere. */ -function getBinaryPath(): string { - // Allow explicit override - if (process.env.ZCASH_CLI_BIN && existsSync(process.env.ZCASH_CLI_BIN)) { - return process.env.ZCASH_CLI_BIN - } - +function getBinaryCandidates(): string[] { // On Windows, Rust produces zcash-cli.exe const isWin = process.platform === "win32" const bin = isWin ? "zcash-cli.exe" : "zcash-cli" const candidates: string[] = [] + if (process.env.ZCASH_CLI_BIN) candidates.push(process.env.ZCASH_CLI_BIN) // 1. cwd-relative (works if cwd is the project root) const cwdRoot = process.cwd() @@ -87,17 +83,29 @@ function getBinaryPath(): string { // 5. Fallback: walk further up in case bundle structure differs const appBundleDir = resolve(import.meta.dir, "..", "..", "..") candidates.push(join(appBundleDir, bin)) + return candidates +} + +/** Resolve the sidecar without throwing. Release capability checks use this + * before advertising Orchard support; an x64 macOS bundle intentionally has + * no sidecar and must degrade to transparent Zcash instead of exposing a + * privacy-engine button that can only fail. */ +export function findZcashCliBinary(): string | undefined { + return getBinaryCandidates().find(p => existsSync(p)) +} + +function getBinaryPath(): string { + const candidates = getBinaryCandidates() - console.log(`[zcash-sidecar] Searching for binary (cwd=${cwdRoot}, import.meta.dir=${import.meta.dir})`) + console.log(`[zcash-sidecar] Searching for binary (cwd=${process.cwd()}, import.meta.dir=${import.meta.dir})`) for (const p of candidates) { console.log(`[zcash-sidecar] ${existsSync(p) ? 'FOUND' : 'miss'}: ${p}`) } - for (const p of candidates) { - if (existsSync(p)) { - console.log(`[zcash-sidecar] Found binary: ${p}`) - return p - } + const found = findZcashCliBinary() + if (found) { + console.log(`[zcash-sidecar] Found binary: ${found}`) + return found } const searched = candidates.map(p => ` - ${p}`).join("\n") diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 4a1b7379..7353923e 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -963,6 +963,10 @@ function App() { { setWatchOnlyDeviceId(id); setWatchOnlyLabel(label); setWatchOnlyMode(true) }} onReady={() => setGridReady(true)} + onEnableEmulator={async () => { + const settings = await rpcRequest('setEmulatorEnabled', { enabled: true }, 10000) + setEmulatorEnabled(settings.emulatorEnabled) + }} emulatorEnabled={emulatorEnabled} /> {/* Windows: a connected KeepKey can be invisible to the app if WinUSB diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 8ab77372..773dd799 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -107,7 +107,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // BTC multi-account support const isBtc = chain.id === 'bitcoin' - const { btcAccounts, selectXpub, addAccount, refresh: refreshBtcAccounts, loading: btcLoading } = useBtcAccounts() + const { btcAccounts, selectXpub, addAccount, refresh: refreshBtcAccounts, loading: btcLoading, error: btcAccountsError } = useBtcAccounts() // Single-chain refresh. Always forced (bun getBalance passes forceRefresh:true // to Pioneer). The result is NOT kept locally: the backend pushes the identical @@ -1135,13 +1135,40 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi {/* Account selectors call device/backend account RPCs and mix live wallet account state with the cached receive address — hidden in watch-only. */} - {!watchOnly && isBtc && btcAccounts.accounts.length > 0 && ( - + {!watchOnly && isBtc && ( + btcAccounts.accounts.length > 0 ? ( + + ) : btcLoading ? ( + + + Loading Bitcoin account types… + + ) : ( + + + Bitcoin account types unavailable + + {btcAccountsError || "Vault did not receive the required Bitcoin xpubs."} + + + + + ) )} {!watchOnly && !isHiddenWallet && isAltUtxo && ( ([]) const [historyFilter, setHistoryFilter] = useState<"all" | "signed" | "blocked">("all") const [expandedEvent, setExpandedEvent] = useState(null) + const [wordCount, setWordCount] = useState<12 | 18 | 24>(12) + const [bip85Index, setBip85Index] = useState(0) + const [childMnemonic, setChildMnemonic] = useState("") + const [providerKey, setProviderKey] = useState<{ publicKeyHex: string; fingerprint: string; filePath: string } | null>(null) const [busy, setBusy] = useState("") const [error, setError] = useState("") const [notice, setNotice] = useState("") @@ -254,6 +258,40 @@ export function ClearSignStudio({ open, onClose, advancedMode, firmwareVersion } } }, [alias, publicKeyInput, refreshHistory, slot]) + const showChildSeed = useCallback(async () => { + setBusy("bip85") + setError("") + setNotice("") + try { + await rpcRequest("getBip85Mnemonic", { wordCount, index: bip85Index }, 0) + setNotice(`Look at your KeepKey: the ${wordCount}-word child seed for index ${bip85Index} is on screen. Type it below, then let the screen clear.`) + } catch (cause: any) { + setError(cause?.message || String(cause)) + } finally { + setBusy("") + } + }, [bip85Index, wordCount]) + + const deriveProvider = useCallback(async () => { + setBusy("derive") + setError("") + setNotice("") + try { + const result = await rpcRequest<{ publicKeyHex: string; fingerprint: string; filePath: string }>( + "clearsignDeriveProviderKey", + { childMnemonic, alias, wordCount, index: bip85Index }, + ) + setProviderKey(result) + setChildMnemonic("") + setNotice(`Provider key ${result.fingerprint} written to ${result.filePath}. The words are cleared from this screen.`) + } catch (cause: any) { + setProviderKey(null) + setError(cause?.message || String(cause)) + } finally { + setBusy("") + } + }, [alias, bip85Index, childMnemonic, wordCount]) + const copy = useCallback(async (name: string, value: string) => { if (await copyText(value)) { setCopied(name) @@ -298,9 +336,9 @@ export function ClearSignStudio({ open, onClose, advancedMode, firmwareVersion } - {(["author", "signer", "evidence"] as StudioTab[]).map(value => ( + {(["author", "provider", "signer", "evidence"] as StudioTab[]).map(value => ( ))} @@ -394,6 +432,32 @@ export function ClearSignStudio({ open, onClose, advancedMode, firmwareVersion } )} + {tab === "provider" && ( + + + 1 · Derive a child seed on the device + BIP-85 gives no custody: the key ends up hot inside a live service. What it gives is a ceremony you can repeat and audit from device + index instead of a key file of unexplained origin. + Provider alias { setAlias(event.target.value); setProviderKey(null) }} maxLength={31} size="sm" bg="rgba(0,0,0,0.18)" /> + Words{([12, 18, 24] as const).map(value => )} + Index { setBip85Index(Math.max(0, Number(event.target.value) || 0)); setProviderKey(null) }} size="sm" w="140px" bg="rgba(0,0,0,0.18)" /> + + The words are displayed on the KeepKey screen only — they never cross USB. Read them off the device and type them below. + + + + 2 · Type the words back + A typo fails the BIP-39 checksum rather than deriving a plausible key whose fingerprint never matches any device. + BIP-85 child mnemonic