Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,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 publish release upload-dmg submodules modules-install modules-build modules-clean audit
.PHONY: install dev dev-hmr build build-stable build-canary build-signed prune-bundle dmg clean help vault sign-check verify publish release upload-dmg submodules modules-install modules-build modules-clean audit build-zcash-cli build-zcash-cli-debug

# --- Submodules (auto-init on fresh worktrees/clones) ---

Expand All @@ -29,6 +29,14 @@ modules-clean:
cd modules/proto-tx-builder && rm -rf dist node_modules
cd modules/hdwallet && yarn clean 2>/dev/null || (rm -rf packages/*/dist node_modules)

# --- Zcash CLI Sidecar (Rust) ---

build-zcash-cli:
cd $(PROJECT_DIR)/zcash-cli && cargo build --release

build-zcash-cli-debug:
cd $(PROJECT_DIR)/zcash-cli && cargo build

# --- Vault ---

install: modules-build
Expand Down Expand Up @@ -80,7 +88,7 @@ dmg:
echo "Verifying extracted app..."; \
codesign --verify --deep --strict "$$APP" || (echo "ERROR: codesign verification failed"; exit 1); \
ln -s /Applications "$$STAGING/Applications"; \
DMG_OUT="$(PROJECT_DIR)/artifacts/$(DMG_NAME)"; \
DMG_OUT="$$(pwd)/$(PROJECT_DIR)/artifacts/$(DMG_NAME)"; \
rm -f "$$DMG_OUT"; \
echo "Creating DMG..."; \
hdiutil create -volname "KeepKey Vault" -srcfolder "$$STAGING" -ov -format UDZO "$$DMG_OUT"; \
Expand All @@ -96,7 +104,7 @@ dmg:
echo "DMG ready: $$DMG_OUT"

clean: modules-clean
cd $(PROJECT_DIR) && rm -rf dist node_modules build artifacts
cd $(PROJECT_DIR) && rm -rf dist node_modules build _build artifacts

# --- Audit & SBOM ---

Expand All @@ -118,8 +126,8 @@ sign-check:
@security find-identity -v -p codesigning | grep "$$ELECTROBUN_DEVELOPER_ID" || echo "WARNING: Certificate not found in keychain"

verify:
@APP=$$(find $(PROJECT_DIR)/build -name "*.app" -maxdepth 2 | head -1); \
if [ -z "$$APP" ]; then echo "No .app bundle found in build/"; exit 1; fi; \
@APP=$$(find $(PROJECT_DIR)/_build -name "*.app" -maxdepth 2 | head -1); \
if [ -z "$$APP" ]; then echo "No .app bundle found in _build/"; exit 1; fi; \
echo "Verifying: $$APP"; \
echo "--- codesign ---"; \
codesign --verify --deep --strict "$$APP" && echo "codesign: PASS" || echo "codesign: FAIL"; \
Expand Down Expand Up @@ -184,6 +192,8 @@ help:
@echo " make dmg - Create DMG from existing build artifacts"
@echo " make modules-build - Build hdwallet + proto-tx-builder from source"
@echo " make modules-clean - Clean module build artifacts"
@echo " make build-zcash-cli - Build Zcash CLI sidecar (release)"
@echo " make build-zcash-cli-debug - Build Zcash CLI sidecar (debug)"
@echo " make audit - Generate dependency manifest + SBOM"
@echo " make sign-check - Verify signing env vars are configured"
@echo " make verify - Verify .app bundle signature + Gatekeeper"
Expand Down
2 changes: 1 addition & 1 deletion modules/device-protocol
2 changes: 1 addition & 1 deletion modules/hdwallet
5 changes: 3 additions & 2 deletions projects/keepkey-vault/electrobun.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ export default {
app: {
name: "keepkey-vault",
identifier: "com.keepkey.vault",
version: "1.1.1",
version: "1.1.2",
urlSchemes: ["keepkey"],
},
build: {
buildFolder: "_build",
bun: {
// Mark native addons and protobuf-dependent packages as external
// so Bun loads them at runtime instead of bundling them.
Expand All @@ -29,7 +30,7 @@ export default {
copy: {
"dist/index.html": "views/mainview/index.html",
"dist/assets": "views/mainview/assets",
"build/_ext_modules": "node_modules",
"_build/_ext_modules": "node_modules",
},
mac: {
bundleCEF: false,
Expand Down
4 changes: 3 additions & 1 deletion projects/keepkey-vault/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "keepkey-vault",
"version": "1.1.1",
"version": "1.1.2",
"description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun",
"scripts": {
"dev": "vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev",
Expand All @@ -16,6 +16,7 @@
"dependencies": {
"@chakra-ui/react": "^3.33.0",
"@emotion/react": "^11.14.0",
"@keepkey/device-protocol": "file:../../modules/device-protocol",
"@keepkey/hdwallet-core": "file:../../modules/hdwallet/packages/hdwallet-core",
"@keepkey/hdwallet-keepkey": "file:../../modules/hdwallet/packages/hdwallet-keepkey",
"@keepkey/hdwallet-keepkey-nodehid": "file:../../modules/hdwallet/packages/hdwallet-keepkey-nodehid",
Expand Down Expand Up @@ -54,6 +55,7 @@
"tiny-secp256k1"
],
"overrides": {
"@keepkey/device-protocol": "file:../../modules/device-protocol",
"@keepkey/proto-tx-builder": "file:../../modules/proto-tx-builder",
"@keepkey/hdwallet-core": "file:../../modules/hdwallet/packages/hdwallet-core",
"@keepkey/hdwallet-keepkey": "file:../../modules/hdwallet/packages/hdwallet-keepkey",
Expand Down
20 changes: 19 additions & 1 deletion projects/keepkey-vault/scripts/collect-externals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const EXTERNALS = [

const projectRoot = join(import.meta.dir, '..')
const nmSource = join(projectRoot, 'node_modules')
const nmDest = join(projectRoot, 'build', '_ext_modules')
const nmDest = join(projectRoot, '_build', '_ext_modules')

// Resolve file: linked packages to their actual source directories.
// Bun's file: resolution can leave broken stubs in node_modules (empty dir with only node_modules/).
Expand Down Expand Up @@ -621,6 +621,24 @@ if (DEVELOPER_ID) {
console.log(`[collect-externals] ELECTROBUN_DEVELOPER_ID not set, skipping native binary signing`)
}

// Remove dangling symlinks (left behind after pruning/stripping)
function removeDanglingSymlinks(dirPath: string) {
try {
const entries = readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isSymbolicLink()) {
try { statSync(fullPath) } catch {
rmSync(fullPath)
}
} else if (entry.isDirectory()) {
removeDanglingSymlinks(fullPath)
}
}
} catch {}
}
removeDanglingSymlinks(nmDest)

// Report final size
const { stdout } = Bun.spawnSync(['du', '-sh', nmDest])
console.log(`[collect-externals] Final size: ${stdout.toString().trim().split('\t')[0]}`)
21 changes: 18 additions & 3 deletions projects/keepkey-vault/src/bun/engine-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,16 @@ export class EngineController extends EventEmitter {
})
}, delay)
}

// Same for needs_passphrase — device has passphrase protection but PIN is
// already cached (or disabled). We must call promptPin() → getPublicKeys()
// so the device sends PASSPHRASE_REQUEST; without it, the UI overlay shows
// but sendPassphrase() has no pending device request to respond to.
if (state === 'needs_passphrase' && !this.promptPinActive) {
this.promptPin().catch(err => {
console.warn('[Engine] Auto prompt-passphrase failed:', err?.message)
})
}
}

// ── Firmware Manifest ──────────────────────────────────────────────────
Expand Down Expand Up @@ -419,11 +429,12 @@ export class EngineController extends EventEmitter {
this.rebootPollCount++
if (this.rebootPollCount >= EngineController.MAX_REBOOT_POLLS) {
console.warn('[Engine] Reboot poll: max attempts reached (5 min), stopping')
this.updatePhase = null
this.updatePhase = 'idle'
this.stopRebootPoll()
this.updateState('disconnected')
return
}
console.log(`[Engine] Reboot poll ${this.rebootPollCount}/${EngineController.MAX_REBOOT_POLLS}: calling syncState()`)
if (this.rebootPollCount % 6 === 0) console.log(`[Engine] Reboot poll ${this.rebootPollCount}/${EngineController.MAX_REBOOT_POLLS}: calling syncState()`)
this.syncState()
}, 5000)
}
Expand Down Expand Up @@ -881,7 +892,11 @@ export class EngineController extends EventEmitter {
if (opts.autoLockDelayMs !== undefined) settings.autoLockDelayMs = opts.autoLockDelayMs
await this.wallet.applySettings(settings)
this.cachedFeatures = await this.wallet.getFeatures()
this.emit('state-change', this.getDeviceState())
// Route through updateState so needs_passphrase triggers promptPin() →
// getPublicKeys() → PASSPHRASE_REQUEST. Previously this emitted directly,
// so enabling passphrase from settings showed the overlay but the device
// never received the passphrase (no pending request to respond to).
this.updateState(this.deriveState(this.cachedFeatures))
}

async changePin() {
Expand Down
63 changes: 62 additions & 1 deletion projects/keepkey-vault/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { startRestApi, type RestApiCallbacks } from "./rest-api"
import { AuthStore } from "./auth"
import { getPioneer, getPioneerApiBase, resetPioneer } from "./pioneer"
import { buildTx, broadcastTx } from "./txbuilder"
import { CHAINS, customChainToChainDef } from "../shared/chains"
import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, sendShielded } from "./txbuilder/zcash-shielded"
import { isSidecarReady, startSidecar, stopSidecar, hasFvkLoaded, getCachedFvk, setCachedFvk, onScanProgress } from "./zcash-sidecar"
import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chains"
import type { ChainDef } from "../shared/chains"
import { BtcAccountManager } from "./btc-accounts"
import { EvmAddressManager, evmAddressPath } from "./evm-addresses"
Expand Down Expand Up @@ -250,11 +252,13 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
analyzeFirmware: async (params) => {
if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)')
const buf = Buffer.from(params.data, 'base64')
if (buf.length > 7_500_000) throw new Error('Decoded firmware exceeds 7.5MB limit')
return engine.analyzeFirmware(buf)
},
flashCustomFirmware: async (params) => {
if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)')
const buf = Buffer.from(params.data, 'base64')
if (buf.length > 7_500_000) throw new Error('Decoded firmware exceeds 7.5MB limit')
await engine.flashCustomFirmware(buf)
},
resetDevice: async (params) => { await engine.resetDevice(params) },
Expand Down Expand Up @@ -1138,6 +1142,57 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
return Object.fromEntries(map)
},

// ── Zcash Shielded (Orchard) ────────────────────────────
zcashShieldedStatus: async () => {
const zcashDef = CHAINS.find(c => c.id === 'zcash-shielded')
if (!zcashDef || !isChainSupported(zcashDef, engine.state?.firmwareVersion)) {
return { ready: false, fvk_loaded: false, address: null, fvk: null }
}
// Lazy-start sidecar on first status check
if (!isSidecarReady()) {
try { await startSidecar() } catch { /* binary not found — will show not_running */ }
}
const cached = getCachedFvk()
return {
ready: isSidecarReady(),
fvk_loaded: hasFvkLoaded(),
address: cached?.address ?? null,
fvk: cached?.fvk ?? null,
}
},
zcashShieldedInit: async (params) => {
const zcashDef = CHAINS.find(c => c.id === 'zcash-shielded')
if (!zcashDef || !isChainSupported(zcashDef, engine.state?.firmwareVersion)) {
throw new Error('Zcash shielded requires firmware >= 7.11.0')
}
// If FVK is already loaded from DB, return it immediately
const cached = getCachedFvk()
if (cached) return cached
// Otherwise get from device
if (!engine.wallet) throw new Error('No device connected')
const result = await initializeOrchardFromDevice(engine.wallet as any, params?.account ?? 0)
setCachedFvk(result.address, result.fvk)
return result
},
zcashShieldedScan: async (params) => {
return await scanOrchardNotes(params?.startHeight, params?.fullRescan)
},
zcashShieldedBalance: async () => {
return await getShieldedBalance()
},
zcashShieldedSend: async (params) => {
if (!engine.wallet) throw new Error('No device connected')
const zcashDef = CHAINS.find(c => c.id === 'zcash-shielded')
if (!zcashDef || !isChainSupported(zcashDef, engine.state?.firmwareVersion)) {
throw new Error('Zcash shielded requires firmware >= 7.11.0')
}
return await sendShielded(engine.wallet as any, {
recipient: params.recipient,
amount: params.amount,
memo: params.memo,
})
},

// ── Camera / QR scanning ─────────────────────────────────
startQrScan: async () => {
startCamera(
Expand Down Expand Up @@ -1450,6 +1505,9 @@ engine.on('state-change', (state) => {
engine.on('firmware-progress', (progress) => {
try { rpc.send['firmware-progress'](progress) } catch { /* webview not ready yet */ }
})
onScanProgress((progress) => {
try { rpc.send['scan-progress'](progress) } catch { /* webview not ready yet */ }
})
engine.on('pin-request', (req) => {
try { rpc.send['pin-request'](req) } catch { /* webview not ready yet */ }
})
Expand Down Expand Up @@ -1629,6 +1687,8 @@ if (process.platform === 'win32') {
// Start engine (USB event listeners + initial device sync)
await engine.start()

// Zcash sidecar is started lazily on first zcash RPC call (requires firmware >= 7.11.0)

// Cache app version for REST health endpoint
Updater.localInfo.version().then(v => { appVersionCache = v }).catch(() => {})

Expand Down Expand Up @@ -1666,6 +1726,7 @@ function cleanupAndQuit() {
if (quitting) return
quitting = true
stopCamera()
stopSidecar()
engine.stop()
restServer?.stop()
Utils.quit()
Expand Down
41 changes: 34 additions & 7 deletions projects/keepkey-vault/src/bun/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ import { getPioneerApiBase } from './pioneer'

const REPORT_TIMEOUT_MS = 60_000

/** Section title prefixes — shared with tax-export.ts for reliable extraction. */
export const SECTION_TITLES = {
TX_DETAILS: 'Transaction Details',
TX_HISTORY: 'Transaction History',
} as const

/** Safely round a satoshi string/number to integer, guarding against values beyond Number.MAX_SAFE_INTEGER. */
function safeRoundSats(value: unknown): number {
if (value === undefined || value === null) return 0
const n = Number(value)
if (!Number.isFinite(n)) return 0
if (Math.abs(n) > Number.MAX_SAFE_INTEGER) {
console.warn(`[Report] safeRoundSats: value ${value} exceeds MAX_SAFE_INTEGER, clamping`)
return n > 0 ? Number.MAX_SAFE_INTEGER : -Number.MAX_SAFE_INTEGER
}
return Math.round(n)
}

function getPioneerQueryKey(): string {
return process.env.PIONEER_API_KEY || `key:public-${Date.now()}`
}
Expand Down Expand Up @@ -45,7 +63,12 @@ async function fetchPubkeyInfo(baseUrl: string, xpub: string): Promise<any> {
)
if (!resp.ok) throw new Error(`PubkeyInfo ${resp.status}`)
const json = await resp.json()
return json.data || json
const result = json.data || json
if (typeof result !== 'object' || result === null) {
console.warn('[Report] fetchPubkeyInfo: unexpected response shape, returning empty object')
return {}
}
return result
}

async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Promise<any[]> {
Expand All @@ -60,6 +83,10 @@ async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Prom
)
if (!resp.ok) throw new Error(`TxHistory ${resp.status}`)
const json = await resp.json()
if (typeof json !== 'object' || json === null) {
console.warn('[Report] fetchTxHistory: unexpected response shape, returning empty array')
return []
}
const histories = json.histories || json.data?.histories || []
return histories[0]?.transactions || []
}
Expand Down Expand Up @@ -257,9 +284,9 @@ async function buildBtcSections(
xpub: x.xpub,
scriptType: x.scriptType,
label: `${x.scriptType}`,
balance: Math.round(Number(info.balance || 0)),
totalReceived: Math.round(Number(info.totalReceived || 0)),
totalSent: Math.round(Number(info.totalSent || 0)),
balance: safeRoundSats(info.balance),
totalReceived: safeRoundSats(info.totalReceived),
totalSent: safeRoundSats(info.totalSent),
txCount: info.txs || 0,
usedAddresses: used.map((t: any) => ({
name: t.name, path: t.path, transfers: t.transfers,
Expand Down Expand Up @@ -380,7 +407,7 @@ async function buildBtcSections(
})

sections.push({
title: `Transaction History (${allTxs.length})`,
title: `${SECTION_TITLES.TX_HISTORY} (${allTxs.length})`,
type: 'table',
data: {
headers: ['#', 'Dir', 'TXID', 'Block', 'Date', 'Value (BTC)', 'Fee (BTC)'],
Expand Down Expand Up @@ -412,7 +439,7 @@ async function buildBtcSections(
const detailTxs = allTxs.slice(0, MAX_TX_DETAILS)
if (detailTxs.length > 0) {
sections.push({
title: `Transaction Details (${Math.min(allTxs.length, MAX_TX_DETAILS)}${allTxs.length > MAX_TX_DETAILS ? ` of ${allTxs.length}` : ''})`,
title: `${SECTION_TITLES.TX_DETAILS} (${Math.min(allTxs.length, MAX_TX_DETAILS)}${allTxs.length > MAX_TX_DETAILS ? ` of ${allTxs.length}` : ''})`,
type: 'table',
data: {
headers: ['TXID', 'Dir', 'Block', 'Date', 'Value (BTC)', 'Fee (BTC)', 'From', 'To'],
Expand Down Expand Up @@ -440,7 +467,7 @@ async function buildBtcSections(
sections.push({ title: 'Note', type: 'text', data: `Showing ${MAX_TX_DETAILS} of ${allTxs.length} transactions.` })
}
} else {
sections.push({ title: 'Transaction History', type: 'text', data: 'No transactions found' })
sections.push({ title: SECTION_TITLES.TX_HISTORY, type: 'text', data: 'No transactions found' })
}

// 3. Address flow analysis from tx data
Expand Down
Loading
Loading